From 4e3688eaff5903b2f84a6f6abd7de94427e0bd25 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 10:00:25 -0500 Subject: [PATCH 1/6] fix(openclaw): simplify fallback agents and allow legacy archived path Remove the hardcoded archived fallback agent for fresh installs and extend workspace path allowlisting to permit /_archived_workspace_main when it is intentionally present. --- src/routes/openclaw.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index 3e1d8a7..0cf24e3 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -84,6 +84,13 @@ function isAllowedWorkspacePath(workspacePath) { // Allow skills directory (moved from /shared/skills to /skills) if (workspacePath.startsWith('/skills/') || workspacePath === '/skills') return true; + // Allow legacy archived workspace path when present + if ( + workspacePath === '/_archived_workspace_main' || + workspacePath.startsWith('/_archived_workspace_main/') + ) + return true; + // Allow agent workspaces if (workspacePath.startsWith('/workspace/') || workspacePath === '/workspace') return true; if (workspacePath.startsWith('/workspace-') || /^\/workspace-[a-z]+(\/|$)/.test(workspacePath)) From baef1e9adee9457b41c4ae441ff7aadf9e7a1818 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 12:55:19 -0500 Subject: [PATCH 2/6] feat(openclaw): remap absolute paths before workspace allowlist Add OPENCLAW_PATH_REMAP_PREFIXES support to normalize host-absolute workspace paths into virtual paths before allowlist/forwarding, keep archived fallback agent, and add integration coverage for remap + fallback behavior. --- .env.example | 2 + README.md | 6 ++- docs/configuration.md | 1 + docs/getting-started/first-run.md | 1 + src/config.js | 3 ++ .../__tests__/openclaw.integration.test.js | 54 +++++++++++++++++++ src/routes/openclaw.js | 47 +++++++++++++--- 7 files changed, 105 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 4a44eb9..80b91a2 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,8 @@ ARCHIVE_ON_STARTUP=false # Production: http://openclaw-workspace..svc.cluster.local:8080 OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token +# Optional: remap host-absolute workspace paths (comma-separated prefixes) +OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw # ── OpenClaw Gateway Integration (optional) ────────────────── # Local dev: kubectl port-forward -n svc/openclaw 18789:18789 diff --git a/README.md b/README.md index 2699fa9..ca78120 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,11 @@ To use agent management, workspace browsing, and org chart features, MosBot API - **OpenClaw runs on a VPS or remote host** — Expose ports 8080 and 18789 on the VPS (firewall/security group). If MosBot API runs on the **same** VPS, use `http://localhost:8080` and `http://localhost:18789`. If the API runs elsewhere, use the VPS hostname or IP (e.g. `http://openclaw.example.com:8080`). Prefer a VPN or private network when exposing these services across the internet. -Add to `.env`: `OPENCLAW_WORKSPACE_URL`, `OPENCLAW_WORKSPACE_TOKEN`, `OPENCLAW_GATEWAY_URL`, `OPENCLAW_GATEWAY_TOKEN`. See [docs/openclaw/README.md](docs/openclaw/README.md) and [docs/guides/openclaw-local-development.md](docs/guides/openclaw-local-development.md) for details. +Add to `.env`: `OPENCLAW_WORKSPACE_URL`, `OPENCLAW_WORKSPACE_TOKEN`, `OPENCLAW_GATEWAY_URL`, +`OPENCLAW_GATEWAY_TOKEN`, and optionally `OPENCLAW_PATH_REMAP_PREFIXES` (default: +`/home/node/.openclaw`) when your agent workspaces are reported as host-absolute paths. See +[docs/openclaw/README.md](docs/openclaw/README.md) and +[docs/guides/openclaw-local-development.md](docs/guides/openclaw-local-development.md) for details. > **Production build:** to run the dashboard as an optimised nginx bundle instead, use `make up-prod` (or `docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build`). This is only needed for production deployments — day-to-day development uses `make up`. diff --git a/docs/configuration.md b/docs/configuration.md index 8adc501..414a012 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,7 @@ Test database (optional — used for integration tests when set): | -------- | ------- | ----------- | | `OPENCLAW_WORKSPACE_URL` | — | URL of the OpenClaw workspace service | | `OPENCLAW_WORKSPACE_TOKEN` | — | Bearer token for workspace service auth | +| `OPENCLAW_PATH_REMAP_PREFIXES` | `/home/node/.openclaw` | Comma-separated host path prefixes remapped to virtual workspace paths before allowlist checks | ## OpenClaw Gateway (optional) diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index 9156459..c974601 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -75,6 +75,7 @@ If you have an OpenClaw instance, add the integration variables to `.env`: ```bash OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token +OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw OPENCLAW_GATEWAY_URL=http://localhost:18789 OPENCLAW_GATEWAY_TOKEN=your-gateway-token ``` diff --git a/src/config.js b/src/config.js index 6615ceb..62397ff 100644 --- a/src/config.js +++ b/src/config.js @@ -64,6 +64,9 @@ const config = { get workspaceToken() { return process.env.OPENCLAW_WORKSPACE_TOKEN || null; }, + get pathRemapPrefixes() { + return process.env.OPENCLAW_PATH_REMAP_PREFIXES || '/home/node/.openclaw'; + }, subagentRetentionDays: parseInt(process.env.SUBAGENT_RETENTION_DAYS || '30', 10), activityLogRetentionDays: parseInt(process.env.ACTIVITY_LOG_RETENTION_DAYS || '7', 10), }, diff --git a/src/routes/__tests__/openclaw.integration.test.js b/src/routes/__tests__/openclaw.integration.test.js index 8790d04..4dd6988 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -55,12 +55,14 @@ describe('OpenClaw Workspace Access Control', () => { originalFetch = global.fetch; mockOpenClawUrl = 'http://mock-openclaw:8080'; process.env.OPENCLAW_WORKSPACE_URL = mockOpenClawUrl; + process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/home/node/.openclaw'; }); afterAll(() => { // Restore original fetch global.fetch = originalFetch; delete process.env.OPENCLAW_WORKSPACE_URL; + delete process.env.OPENCLAW_PATH_REMAP_PREFIXES; }); beforeEach(() => { @@ -137,6 +139,34 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.body.error.message).toBe('Invalid or expired token'); expect(global.fetch).not.toHaveBeenCalled(); }); + + it('should remap host-absolute OpenClaw paths before forwarding', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '/home/node/.openclaw/workspace/test.txt', recursive: 'false' }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace%2Ftest.txt&recursive=false'), + expect.any(Object), + ); + }); + + it('should reject non-remapped absolute paths outside allowlist', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '/tmp/not-allowed', recursive: 'false' }); + + expect(response.status).toBe(403); + expect(response.body.error.code).toBe('PATH_NOT_ALLOWED'); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); describe('GET /api/v1/openclaw/workspace/files/content', () => { @@ -635,4 +665,28 @@ describe('OpenClaw Workspace Access Control', () => { expect(global.fetch).not.toHaveBeenCalled(); }); }); + + describe('GET /api/v1/openclaw/agents fallback', () => { + it('returns COO + archived fallback when config is unreadable', async () => { + const token = getToken('admin-id', 'admin'); + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + const response = await request(app) + .get('/api/v1/openclaw/agents') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0].id).toBe('coo'); + expect(response.body.data[0].workspace).toBe('/workspace'); + expect(response.body.data[1].id).toBe('archived'); + expect(response.body.data[1].workspace).toBe('/home/node/.openclaw/_archived_workspace_main'); + }); + }); }); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index 0cf24e3..3cbb6fb 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -62,6 +62,37 @@ function normalizeAndValidateWorkspacePath(inputPath) { return normalized; } +function getOpenClawPathRemapPrefixes() { + const rawPrefixes = config.openclaw.pathRemapPrefixes || '/home/node/.openclaw'; + return String(rawPrefixes) + .split(',') + .map((prefix) => normalizeAndValidateWorkspacePath(prefix)) + .map((prefix) => (prefix === '/' ? prefix : prefix.replace(/\/+$/, ''))) + .filter(Boolean); +} + +function remapWorkspacePathPrefixes(workspacePath) { + const prefixes = getOpenClawPathRemapPrefixes(); + + for (const prefix of prefixes) { + if (workspacePath === prefix) { + return '/'; + } + + if (workspacePath.startsWith(`${prefix}/`)) { + const remapped = workspacePath.substring(prefix.length); + return normalizeAndValidateWorkspacePath(remapped); + } + } + + return workspacePath; +} + +function normalizeRemapAndValidateWorkspacePath(inputPath) { + const normalizedPath = normalizeAndValidateWorkspacePath(inputPath); + return remapWorkspacePathPrefixes(normalizedPath); +} + /** * Validate that a workspace path is allowed for access * @param {string} workspacePath - Normalized workspace path @@ -121,7 +152,7 @@ function toUpdatedAtMs(val) { router.get('/workspace/files', requireAuth, async (req, res, next) => { try { const { path: inputPath = '/', recursive = 'false' } = req.query; - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed if (!isAllowedWorkspacePath(workspacePath)) { @@ -164,7 +195,7 @@ router.get('/workspace/files/content', requireAuth, async (req, res, next) => { }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed if (!isAllowedWorkspacePath(workspacePath)) { @@ -218,7 +249,7 @@ router.post('/workspace/files', requireAuth, requireAdmin, async (req, res, next }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Restrict system config files to admin/owner only (exclude 'agent' role) const isSystemConfigFile = @@ -323,7 +354,7 @@ router.put('/workspace/files', requireAuth, requireAdmin, async (req, res, next) }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed if (!isAllowedWorkspacePath(workspacePath)) { @@ -398,7 +429,7 @@ router.delete('/workspace/files', requireAuth, requireAdmin, async (req, res, ne }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed if (!isAllowedWorkspacePath(workspacePath)) { @@ -532,7 +563,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { title: null, description: 'Operations and workflow management', icon: '📊', - workspace: '/home/node/.openclaw/workspace', + workspace: '/workspace', isDefault: true, }, ]; @@ -580,7 +611,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { label: 'Chief Operating Officer', description: 'Operations and workflow management', icon: '📊', - workspace: '/home/node/.openclaw/workspace', + workspace: '/workspace', isDefault: true, }, { @@ -4205,7 +4236,7 @@ router.get('/config/backups/content', requireAuth, requireOwnerOrAdmin, async (r }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Restrict to backup directory only if (!workspacePath.startsWith('/shared/backups/openclaw-config/')) { From b3d9318d588174f33123f6956605b89466da3e78 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 13:58:40 -0500 Subject: [PATCH 3/6] feat(openclaw): support split roots and native ~/.openclaw path remap Implement API-side OpenClaw path normalization for both /home/node/.openclaw and ~/.openclaw prefixes, preserving allowlist enforcement. Add main/default agent workspace mapping to '/' when workspace is omitted to avoid /workspace-main lookups. Update integration tests and env/docs defaults so remap behavior and deployment config are consistent. --- .env.example | 2 +- README.md | 2 +- docs/configuration.md | 2 +- docs/getting-started/first-run.md | 2 +- src/config.js | 2 +- .../__tests__/openclaw.integration.test.js | 88 ++++++++++++++++++- src/routes/openclaw.js | 15 +++- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 80b91a2..91d85dd 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,7 @@ ARCHIVE_ON_STARTUP=false OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token # Optional: remap host-absolute workspace paths (comma-separated prefixes) -OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw +OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw,~/.openclaw # ── OpenClaw Gateway Integration (optional) ────────────────── # Local dev: kubectl port-forward -n svc/openclaw 18789:18789 diff --git a/README.md b/README.md index ca78120..1961c20 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ To use agent management, workspace browsing, and org chart features, MosBot API Add to `.env`: `OPENCLAW_WORKSPACE_URL`, `OPENCLAW_WORKSPACE_TOKEN`, `OPENCLAW_GATEWAY_URL`, `OPENCLAW_GATEWAY_TOKEN`, and optionally `OPENCLAW_PATH_REMAP_PREFIXES` (default: -`/home/node/.openclaw`) when your agent workspaces are reported as host-absolute paths. See +`/home/node/.openclaw,~/.openclaw`) when your agent workspaces are reported as host-absolute paths. See [docs/openclaw/README.md](docs/openclaw/README.md) and [docs/guides/openclaw-local-development.md](docs/guides/openclaw-local-development.md) for details. diff --git a/docs/configuration.md b/docs/configuration.md index 414a012..af8e38d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,7 +68,7 @@ Test database (optional — used for integration tests when set): | -------- | ------- | ----------- | | `OPENCLAW_WORKSPACE_URL` | — | URL of the OpenClaw workspace service | | `OPENCLAW_WORKSPACE_TOKEN` | — | Bearer token for workspace service auth | -| `OPENCLAW_PATH_REMAP_PREFIXES` | `/home/node/.openclaw` | Comma-separated host path prefixes remapped to virtual workspace paths before allowlist checks | +| `OPENCLAW_PATH_REMAP_PREFIXES` | `/home/node/.openclaw,~/.openclaw` | Comma-separated host path prefixes remapped to virtual workspace paths before allowlist checks | ## OpenClaw Gateway (optional) diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index c974601..a90f088 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -75,7 +75,7 @@ If you have an OpenClaw instance, add the integration variables to `.env`: ```bash OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token -OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw +OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw,~/.openclaw OPENCLAW_GATEWAY_URL=http://localhost:18789 OPENCLAW_GATEWAY_TOKEN=your-gateway-token ``` diff --git a/src/config.js b/src/config.js index 62397ff..6a8da59 100644 --- a/src/config.js +++ b/src/config.js @@ -65,7 +65,7 @@ const config = { return process.env.OPENCLAW_WORKSPACE_TOKEN || null; }, get pathRemapPrefixes() { - return process.env.OPENCLAW_PATH_REMAP_PREFIXES || '/home/node/.openclaw'; + return process.env.OPENCLAW_PATH_REMAP_PREFIXES || '/home/node/.openclaw,~/.openclaw'; }, subagentRetentionDays: parseInt(process.env.SUBAGENT_RETENTION_DAYS || '30', 10), activityLogRetentionDays: parseInt(process.env.ACTIVITY_LOG_RETENTION_DAYS || '7', 10), diff --git a/src/routes/__tests__/openclaw.integration.test.js b/src/routes/__tests__/openclaw.integration.test.js index 4dd6988..303a0d1 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -55,7 +55,7 @@ describe('OpenClaw Workspace Access Control', () => { originalFetch = global.fetch; mockOpenClawUrl = 'http://mock-openclaw:8080'; process.env.OPENCLAW_WORKSPACE_URL = mockOpenClawUrl; - process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/home/node/.openclaw'; + process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/home/node/.openclaw,~/.openclaw'; }); afterAll(() => { @@ -155,6 +155,21 @@ describe('OpenClaw Workspace Access Control', () => { ); }); + it('should remap tilde OpenClaw paths before forwarding', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '~/.openclaw/workspace/foo', recursive: 'false' }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace%2Ffoo&recursive=false'), + expect.any(Object), + ); + }); + it('should reject non-remapped absolute paths outside allowlist', async () => { const token = getToken('admin-id', 'admin'); @@ -214,6 +229,21 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.body.data).toBeDefined(); }); + it('should remap tilde config paths to /openclaw.json', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files/content') + .set('Authorization', `Bearer ${token}`) + .query({ path: '~/.openclaw/openclaw.json' }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files/content?path=%2Fopenclaw.json'), + expect.any(Object), + ); + }); + it('should deny regular user access to read file content (403)', async () => { const token = getToken('user-id', 'user'); @@ -666,6 +696,62 @@ describe('OpenClaw Workspace Access Control', () => { }); }); + describe('GET /api/v1/openclaw/agents mapping', () => { + it('maps missing workspace for default/main agents to root and others to /workspace-', async () => { + const token = getToken('admin-id', 'admin'); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + content: JSON.stringify({ + agents: { + list: [ + { + id: 'main', + name: 'main', + default: false, + }, + { + id: 'coo', + name: 'coo', + default: true, + }, + { + id: 'helper', + name: 'helper', + }, + { + id: 'clawboard-worker', + name: 'Clawboard Worker', + workspace: '~/.openclaw/workspace-clawboard-worker', + }, + ], + }, + }), + }), + text: async () => 'OK', + }); + + const response = await request(app) + .get('/api/v1/openclaw/agents') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body.data)).toBe(true); + + const mainAgent = response.body.data.find((a) => a.id === 'main'); + const defaultAgent = response.body.data.find((a) => a.id === 'coo'); + const helperAgent = response.body.data.find((a) => a.id === 'helper'); + const explicitWorkspaceAgent = response.body.data.find((a) => a.id === 'clawboard-worker'); + + expect(mainAgent.workspace).toBe('/'); + expect(defaultAgent.workspace).toBe('/'); + expect(helperAgent.workspace).toBe('/workspace-helper'); + expect(explicitWorkspaceAgent.workspace).toBe('~/.openclaw/workspace-clawboard-worker'); + }); + }); + describe('GET /api/v1/openclaw/agents fallback', () => { it('returns COO + archived fallback when config is unreadable', async () => { const token = getToken('admin-id', 'admin'); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index 3cbb6fb..0853cb7 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -93,6 +93,19 @@ function normalizeRemapAndValidateWorkspacePath(inputPath) { return remapWorkspacePathPrefixes(normalizedPath); } +function resolveAgentWorkspacePath(agent) { + if (typeof agent?.workspace === 'string' && agent.workspace.trim()) { + return agent.workspace; + } + + if (agent?.default === true || agent?.id === 'main') { + return '/'; + } + + const agentId = typeof agent?.id === 'string' && agent.id.trim() ? agent.id.trim() : 'agent'; + return `/workspace-${agentId}`; +} + /** * Validate that a workspace path is allowed for access * @param {string} workspacePath - Normalized workspace path @@ -551,7 +564,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { title: agent.identity?.title || null, description: agent.identity?.theme || `${agent.identity?.name || agent.id} workspace`, icon: agent.identity?.emoji || '🤖', - workspace: agent.workspace, + workspace: resolveAgentWorkspacePath(agent), isDefault: agent.default === true, })) : [ From 66ae5a553810f91f4aaea79e0f301c14a184a5e5 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 15:23:34 -0500 Subject: [PATCH 4/6] fix(openclaw): make remap defaults additive and longest-prefix Always include built-in OpenClaw host-path remap prefixes, treat OPENCLAW_PATH_REMAP_PREFIXES as additive extras, and resolve remaps by longest prefix first to avoid nested /workspace path compounding. Also normalize explicit agent workspace values in /openclaw/agents output and align fallback archived workspace to virtual path semantics. Updated integration tests and API docs/env examples to reflect additive behavior and precedence. --- .env.example | 6 +- README.md | 6 +- docs/configuration.md | 2 +- docs/getting-started/first-run.md | 5 +- src/config.js | 2 +- .../__tests__/openclaw.integration.test.js | 67 +++++++++++++++++-- src/routes/openclaw.js | 30 +++++++-- 7 files changed, 101 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 91d85dd..ee67611 100644 --- a/.env.example +++ b/.env.example @@ -54,8 +54,10 @@ ARCHIVE_ON_STARTUP=false # Production: http://openclaw-workspace..svc.cluster.local:8080 OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token -# Optional: remap host-absolute workspace paths (comma-separated prefixes) -OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw,~/.openclaw +# Optional: additional remap prefixes (comma-separated). +# Built-in prefixes are always active: +# /home/node/.openclaw/workspace, ~/.openclaw/workspace, /home/node/.openclaw, ~/.openclaw +OPENCLAW_PATH_REMAP_PREFIXES= # ── OpenClaw Gateway Integration (optional) ────────────────── # Local dev: kubectl port-forward -n svc/openclaw 18789:18789 diff --git a/README.md b/README.md index 1961c20..dc03d16 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,10 @@ To use agent management, workspace browsing, and org chart features, MosBot API - **OpenClaw runs on a VPS or remote host** — Expose ports 8080 and 18789 on the VPS (firewall/security group). If MosBot API runs on the **same** VPS, use `http://localhost:8080` and `http://localhost:18789`. If the API runs elsewhere, use the VPS hostname or IP (e.g. `http://openclaw.example.com:8080`). Prefer a VPN or private network when exposing these services across the internet. Add to `.env`: `OPENCLAW_WORKSPACE_URL`, `OPENCLAW_WORKSPACE_TOKEN`, `OPENCLAW_GATEWAY_URL`, -`OPENCLAW_GATEWAY_TOKEN`, and optionally `OPENCLAW_PATH_REMAP_PREFIXES` (default: -`/home/node/.openclaw,~/.openclaw`) when your agent workspaces are reported as host-absolute paths. See +`OPENCLAW_GATEWAY_TOKEN`, and optionally `OPENCLAW_PATH_REMAP_PREFIXES` for extra host-path +remaps. Built-in prefixes are always active: +`/home/node/.openclaw/workspace`, `~/.openclaw/workspace`, `/home/node/.openclaw`, +`~/.openclaw` (most specific prefix wins). See [docs/openclaw/README.md](docs/openclaw/README.md) and [docs/guides/openclaw-local-development.md](docs/guides/openclaw-local-development.md) for details. diff --git a/docs/configuration.md b/docs/configuration.md index af8e38d..bd311a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,7 +68,7 @@ Test database (optional — used for integration tests when set): | -------- | ------- | ----------- | | `OPENCLAW_WORKSPACE_URL` | — | URL of the OpenClaw workspace service | | `OPENCLAW_WORKSPACE_TOKEN` | — | Bearer token for workspace service auth | -| `OPENCLAW_PATH_REMAP_PREFIXES` | `/home/node/.openclaw,~/.openclaw` | Comma-separated host path prefixes remapped to virtual workspace paths before allowlist checks | +| `OPENCLAW_PATH_REMAP_PREFIXES` | `''` | Comma-separated additional host path prefixes remapped to virtual workspace paths before allowlist checks. Built-ins are always active: `/home/node/.openclaw/workspace`, `~/.openclaw/workspace`, `/home/node/.openclaw`, `~/.openclaw` (most specific prefix wins). | ## OpenClaw Gateway (optional) diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index a90f088..a27a8b7 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -75,7 +75,10 @@ If you have an OpenClaw instance, add the integration variables to `.env`: ```bash OPENCLAW_WORKSPACE_URL=http://localhost:8080 OPENCLAW_WORKSPACE_TOKEN=your-workspace-token -OPENCLAW_PATH_REMAP_PREFIXES=/home/node/.openclaw,~/.openclaw +# Optional extra remap prefixes. Built-ins are always active: +# /home/node/.openclaw/workspace, ~/.openclaw/workspace, /home/node/.openclaw, ~/.openclaw +# Most specific prefix wins when multiple prefixes match. +OPENCLAW_PATH_REMAP_PREFIXES= OPENCLAW_GATEWAY_URL=http://localhost:18789 OPENCLAW_GATEWAY_TOKEN=your-gateway-token ``` diff --git a/src/config.js b/src/config.js index 6a8da59..4cc9111 100644 --- a/src/config.js +++ b/src/config.js @@ -65,7 +65,7 @@ const config = { return process.env.OPENCLAW_WORKSPACE_TOKEN || null; }, get pathRemapPrefixes() { - return process.env.OPENCLAW_PATH_REMAP_PREFIXES || '/home/node/.openclaw,~/.openclaw'; + return process.env.OPENCLAW_PATH_REMAP_PREFIXES || ''; }, subagentRetentionDays: parseInt(process.env.SUBAGENT_RETENTION_DAYS || '30', 10), activityLogRetentionDays: parseInt(process.env.ACTIVITY_LOG_RETENTION_DAYS || '7', 10), diff --git a/src/routes/__tests__/openclaw.integration.test.js b/src/routes/__tests__/openclaw.integration.test.js index 303a0d1..c662726 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -55,7 +55,7 @@ describe('OpenClaw Workspace Access Control', () => { originalFetch = global.fetch; mockOpenClawUrl = 'http://mock-openclaw:8080'; process.env.OPENCLAW_WORKSPACE_URL = mockOpenClawUrl; - process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/home/node/.openclaw,~/.openclaw'; + process.env.OPENCLAW_PATH_REMAP_PREFIXES = ''; }); afterAll(() => { @@ -66,6 +66,8 @@ describe('OpenClaw Workspace Access Control', () => { }); beforeEach(() => { + process.env.OPENCLAW_PATH_REMAP_PREFIXES = ''; + // Mock successful OpenClaw responses global.fetch = jest.fn().mockResolvedValue({ ok: true, @@ -146,7 +148,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/home/node/.openclaw/workspace/test.txt', recursive: 'false' }); + .query({ path: '/home/node/.openclaw/workspace/workspace/test.txt', recursive: 'false' }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( @@ -161,7 +163,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '~/.openclaw/workspace/foo', recursive: 'false' }); + .query({ path: '~/.openclaw/workspace/workspace/foo', recursive: 'false' }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( @@ -170,6 +172,59 @@ describe('OpenClaw Workspace Access Control', () => { ); }); + it('should prioritize the longest matching prefix to avoid nested workspace pathing', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ + path: '~/.openclaw/workspace/workspace-clawboard-worker/foo', + recursive: 'false', + }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace-clawboard-worker%2Ffoo&recursive=false'), + expect.any(Object), + ); + }); + + it('should keep built-in remap prefixes active when custom prefixes are configured', async () => { + const token = getToken('admin-id', 'admin'); + process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/opt/custom'; + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ + path: '~/.openclaw/workspace/workspace-clawboard-worker/foo', + recursive: 'false', + }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace-clawboard-worker%2Ffoo&recursive=false'), + expect.any(Object), + ); + }); + + it('should append custom remap prefixes from env', async () => { + const token = getToken('admin-id', 'admin'); + process.env.OPENCLAW_PATH_REMAP_PREFIXES = '/opt/custom'; + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '/opt/custom/workspace-qa', recursive: 'false' }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace-qa&recursive=false'), + expect.any(Object), + ); + }); + it('should reject non-remapped absolute paths outside allowlist', async () => { const token = getToken('admin-id', 'admin'); @@ -724,7 +779,7 @@ describe('OpenClaw Workspace Access Control', () => { { id: 'clawboard-worker', name: 'Clawboard Worker', - workspace: '~/.openclaw/workspace-clawboard-worker', + workspace: '~/.openclaw/workspace/workspace-clawboard-worker', }, ], }, @@ -748,7 +803,7 @@ describe('OpenClaw Workspace Access Control', () => { expect(mainAgent.workspace).toBe('/'); expect(defaultAgent.workspace).toBe('/'); expect(helperAgent.workspace).toBe('/workspace-helper'); - expect(explicitWorkspaceAgent.workspace).toBe('~/.openclaw/workspace-clawboard-worker'); + expect(explicitWorkspaceAgent.workspace).toBe('/workspace-clawboard-worker'); }); }); @@ -772,7 +827,7 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.body.data[0].id).toBe('coo'); expect(response.body.data[0].workspace).toBe('/workspace'); expect(response.body.data[1].id).toBe('archived'); - expect(response.body.data[1].workspace).toBe('/home/node/.openclaw/_archived_workspace_main'); + expect(response.body.data[1].workspace).toBe('/_archived_workspace_main'); }); }); }); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index 0853cb7..ba44aaf 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -13,6 +13,13 @@ const { recordActivityLogEventSafe } = require('../services/activityLogService') const { parseOpenClawConfig } = require('../utils/configParser'); const { getJwtSecret } = require('../utils/jwt'); +const BUILTIN_OPENCLAW_REMAP_PREFIXES = [ + '/home/node/.openclaw/workspace', + '~/.openclaw/workspace', + '/home/node/.openclaw', + '~/.openclaw', +]; + // Auth middleware - require valid JWT const requireAuth = (req, res, next) => { const authHeader = req.headers.authorization; @@ -63,12 +70,18 @@ function normalizeAndValidateWorkspacePath(inputPath) { } function getOpenClawPathRemapPrefixes() { - const rawPrefixes = config.openclaw.pathRemapPrefixes || '/home/node/.openclaw'; - return String(rawPrefixes) + const extraPrefixes = String(config.openclaw.pathRemapPrefixes || '') .split(',') + .map((prefix) => prefix.trim()) + .filter(Boolean); + + const combined = [...BUILTIN_OPENCLAW_REMAP_PREFIXES, ...extraPrefixes] .map((prefix) => normalizeAndValidateWorkspacePath(prefix)) .map((prefix) => (prefix === '/' ? prefix : prefix.replace(/\/+$/, ''))) .filter(Boolean); + + // Most specific prefix wins to avoid accidental partial remaps. + return [...new Set(combined)].sort((a, b) => b.length - a.length); } function remapWorkspacePathPrefixes(workspacePath) { @@ -95,7 +108,16 @@ function normalizeRemapAndValidateWorkspacePath(inputPath) { function resolveAgentWorkspacePath(agent) { if (typeof agent?.workspace === 'string' && agent.workspace.trim()) { - return agent.workspace; + try { + return normalizeRemapAndValidateWorkspacePath(agent.workspace.trim()); + } catch (error) { + logger.warn('Could not normalize configured agent workspace path', { + agentId: agent.id || null, + workspace: agent.workspace, + error: error.message, + }); + return agent.workspace.trim(); + } } if (agent?.default === true || agent?.id === 'main') { @@ -633,7 +655,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { label: 'Archived (Old Main)', description: 'Archived workspace files from previous iteration', icon: '📦', - workspace: '/home/node/.openclaw/_archived_workspace_main', + workspace: '/_archived_workspace_main', isDefault: false, }, ], From 6b9dc4925226528971e3c610d2f5ae853e6cbea5 Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 16:37:01 -0500 Subject: [PATCH 5/6] fix(openclaw): remove /workspace alias and normalize main fallback --- .../__tests__/openclaw.integration.test.js | 70 ++++++++++++------- src/routes/openclaw.js | 7 +- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/routes/__tests__/openclaw.integration.test.js b/src/routes/__tests__/openclaw.integration.test.js index c662726..5e63487 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -73,7 +73,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 200, json: async () => ({ - files: [{ name: 'test.txt', path: '/workspace/test.txt', type: 'file', size: 100 }], + files: [{ name: 'test.txt', path: '/workspace-main/test.txt', type: 'file', size: 100 }], }), text: async () => 'OK', }); @@ -148,11 +148,14 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/home/node/.openclaw/workspace/workspace/test.txt', recursive: 'false' }); + .query({ + path: '/home/node/.openclaw/workspace/workspace-main/test.txt', + recursive: 'false', + }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace%2Ftest.txt&recursive=false'), + expect.stringContaining('/files?path=%2Fworkspace-main%2Ftest.txt&recursive=false'), expect.any(Object), ); }); @@ -163,11 +166,11 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '~/.openclaw/workspace/workspace/foo', recursive: 'false' }); + .query({ path: '~/.openclaw/workspace/workspace-main/foo', recursive: 'false' }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace%2Ffoo&recursive=false'), + expect.stringContaining('/files?path=%2Fworkspace-main%2Ffoo&recursive=false'), expect.any(Object), ); }); @@ -237,6 +240,19 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.body.error.code).toBe('PATH_NOT_ALLOWED'); expect(global.fetch).not.toHaveBeenCalled(); }); + + it('should reject legacy /workspace path alias', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '/workspace', recursive: 'false' }); + + expect(response.status).toBe(403); + expect(response.body.error.code).toBe('PATH_NOT_ALLOWED'); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); describe('GET /api/v1/openclaw/workspace/files/content', () => { @@ -261,7 +277,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files/content') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -278,7 +294,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files/content') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -305,7 +321,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files/content') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(403); expect(response.body.error.message).toBe('Admin access required'); @@ -315,7 +331,7 @@ describe('OpenClaw Workspace Access Control', () => { it('should deny unauthenticated access (401)', async () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files/content') - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(401); expect(response.body.error.message).toBe('Authorization required'); @@ -356,7 +372,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 201, json: async () => ({ - path: '/workspace/new-file.txt', + path: '/workspace-main/new-file.txt', created: true, }), text: async () => 'Created', @@ -367,7 +383,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/new-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace-main/new-file.txt', content: 'Hello', encoding: 'utf8' }); expect(response.status).toBe(201); expect(response.body.data).toBeDefined(); @@ -394,7 +410,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 201, json: async () => ({ - path: '/workspace/new-file.txt', + path: '/workspace-main/new-file.txt', created: true, }), text: async () => 'Created', @@ -405,7 +421,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/new-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace-main/new-file.txt', content: 'Hello', encoding: 'utf8' }); expect(response.status).toBe(201); expect(response.body.data).toBeDefined(); @@ -417,7 +433,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/new-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace-main/new-file.txt', content: 'Hello', encoding: 'utf8' }); expect(response.status).toBe(403); expect(response.body.error.message).toBe('Admin access required'); @@ -445,7 +461,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 201, json: async () => ({ - path: '/workspace/new-file.txt', + path: '/workspace-main/new-file.txt', created: true, }), text: async () => 'Created', @@ -456,7 +472,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/new-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace-main/new-file.txt', content: 'Hello', encoding: 'utf8' }); expect(response.status).toBe(201); expect(response.body.data).toBeDefined(); @@ -492,7 +508,11 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/existing-file.txt', content: 'New content', encoding: 'utf8' }); + .send({ + path: '/workspace-main/existing-file.txt', + content: 'New content', + encoding: 'utf8', + }); expect(response.status).toBe(409); expect(response.body.error).toBeDefined(); @@ -622,7 +642,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 200, json: async () => ({ - path: '/workspace/test.txt', + path: '/workspace-main/test.txt', updated: true, }), text: async () => 'OK', @@ -635,7 +655,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .put('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/test.txt', content: 'Updated', encoding: 'utf8' }); + .send({ path: '/workspace-main/test.txt', content: 'Updated', encoding: 'utf8' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -648,7 +668,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .put('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/test.txt', content: 'Updated', encoding: 'utf8' }); + .send({ path: '/workspace-main/test.txt', content: 'Updated', encoding: 'utf8' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -660,7 +680,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .put('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/workspace/test.txt', content: 'Updated', encoding: 'utf8' }); + .send({ path: '/workspace-main/test.txt', content: 'Updated', encoding: 'utf8' }); expect(response.status).toBe(403); expect(response.body.error.message).toBe('Admin access required'); @@ -685,7 +705,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .delete('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(204); expect(global.fetch).toHaveBeenCalled(); @@ -697,7 +717,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .delete('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(204); }); @@ -708,7 +728,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .delete('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace/test.txt' }); + .query({ path: '/workspace-main/test.txt' }); expect(response.status).toBe(403); expect(response.body.error.message).toBe('Admin access required'); @@ -825,7 +845,7 @@ describe('OpenClaw Workspace Access Control', () => { expect(Array.isArray(response.body.data)).toBe(true); expect(response.body.data).toHaveLength(2); expect(response.body.data[0].id).toBe('coo'); - expect(response.body.data[0].workspace).toBe('/workspace'); + expect(response.body.data[0].workspace).toBe('/'); expect(response.body.data[1].id).toBe('archived'); expect(response.body.data[1].workspace).toBe('/_archived_workspace_main'); }); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index ba44aaf..ce59e31 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -157,8 +157,7 @@ function isAllowedWorkspacePath(workspacePath) { ) return true; - // Allow agent workspaces - if (workspacePath.startsWith('/workspace/') || workspacePath === '/workspace') return true; + // Allow agent workspaces (canonical main workspace is "/"; no "/workspace" alias) if (workspacePath.startsWith('/workspace-') || /^\/workspace-[a-z]+(\/|$)/.test(workspacePath)) return true; @@ -598,7 +597,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { title: null, description: 'Operations and workflow management', icon: '📊', - workspace: '/workspace', + workspace: '/', isDefault: true, }, ]; @@ -646,7 +645,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { label: 'Chief Operating Officer', description: 'Operations and workflow management', icon: '📊', - workspace: '/workspace', + workspace: '/', isDefault: true, }, { From cd357ec8cc79a6eee5ddc0b20814868b4d5dee6c Mon Sep 17 00:00:00 2001 From: Holden Omans Date: Tue, 3 Mar 2026 18:39:03 -0500 Subject: [PATCH 6/6] Normalize main OpenClaw workspace paths to /workspace - remap host workspace prefixes (~/.openclaw/workspace, /home/node/.openclaw/workspace) into /workspace/*\n- treat missing main/default agent workspaces as /workspace and align fallback COO path\n- allow /workspace and /workspace/* in strict path policy while rejecting bare / paths\n- keep sub-agent/shared/config path rules unchanged\n- update integration tests for remap, allowlist, and agents mapping behavior --- .../__tests__/openclaw.integration.test.js | 71 ++++++++++++------- src/routes/openclaw.js | 37 ++++++++-- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/routes/__tests__/openclaw.integration.test.js b/src/routes/__tests__/openclaw.integration.test.js index 5e63487..30d66f2 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -86,12 +86,12 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/', recursive: 'false' }); + .query({ path: '/workspace', recursive: 'false' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2F&recursive=false'), + expect.stringContaining('/files?path=%2Fworkspace&recursive=false'), expect.any(Object), ); }); @@ -102,7 +102,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/', recursive: 'false' }); + .query({ path: '/workspace', recursive: 'false' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -114,7 +114,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/', recursive: 'false' }); + .query({ path: '/workspace', recursive: 'false' }); expect(response.status).toBe(200); expect(response.body.data).toBeDefined(); @@ -124,7 +124,7 @@ describe('OpenClaw Workspace Access Control', () => { it('should deny unauthenticated access (401)', async () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') - .query({ path: '/', recursive: 'false' }); + .query({ path: '/workspace', recursive: 'false' }); expect(response.status).toBe(401); expect(response.body.error.message).toBe('Authorization required'); @@ -135,7 +135,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', 'Bearer invalid-token') - .query({ path: '/', recursive: 'false' }); + .query({ path: '/workspace', recursive: 'false' }); expect(response.status).toBe(401); expect(response.body.error.message).toBe('Invalid or expired token'); @@ -149,13 +149,13 @@ describe('OpenClaw Workspace Access Control', () => { .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) .query({ - path: '/home/node/.openclaw/workspace/workspace-main/test.txt', + path: '/home/node/.openclaw/workspace/design-docs', recursive: 'false', }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace-main%2Ftest.txt&recursive=false'), + expect.stringContaining('/files?path=%2Fworkspace%2Fdesign-docs&recursive=false'), expect.any(Object), ); }); @@ -166,11 +166,11 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '~/.openclaw/workspace/workspace-main/foo', recursive: 'false' }); + .query({ path: '~/.openclaw/workspace/foo', recursive: 'false' }); expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace-main%2Ffoo&recursive=false'), + expect.stringContaining('/files?path=%2Fworkspace%2Ffoo&recursive=false'), expect.any(Object), ); }); @@ -188,7 +188,9 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace-clawboard-worker%2Ffoo&recursive=false'), + expect.stringContaining( + '/files?path=%2Fworkspace%2Fworkspace-clawboard-worker%2Ffoo&recursive=false', + ), expect.any(Object), ); }); @@ -207,7 +209,9 @@ describe('OpenClaw Workspace Access Control', () => { expect(response.status).toBe(200); expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/files?path=%2Fworkspace-clawboard-worker%2Ffoo&recursive=false'), + expect.stringContaining( + '/files?path=%2Fworkspace%2Fworkspace-clawboard-worker%2Ffoo&recursive=false', + ), expect.any(Object), ); }); @@ -228,7 +232,7 @@ describe('OpenClaw Workspace Access Control', () => { ); }); - it('should reject non-remapped absolute paths outside allowlist', async () => { + it('should reject non-remapped unsupported absolute-looking paths', async () => { const token = getToken('admin-id', 'admin'); const response = await request(app) @@ -241,13 +245,28 @@ describe('OpenClaw Workspace Access Control', () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it('should reject legacy /workspace path alias', async () => { + it('should allow canonical main workspace subpaths under /workspace/*', async () => { const token = getToken('admin-id', 'admin'); const response = await request(app) .get('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .query({ path: '/workspace', recursive: 'false' }); + .query({ path: '/workspace/design-docs', recursive: 'false' }); + + expect(response.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/files?path=%2Fworkspace%2Fdesign-docs&recursive=false'), + expect.any(Object), + ); + }); + + it('should reject main workspace paths outside /workspace/*', async () => { + const token = getToken('admin-id', 'admin'); + + const response = await request(app) + .get('/api/v1/openclaw/workspace/files') + .set('Authorization', `Bearer ${token}`) + .query({ path: '/design-docs', recursive: 'false' }); expect(response.status).toBe(403); expect(response.body.error.code).toBe('PATH_NOT_ALLOWED'); @@ -546,7 +565,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 201, json: async () => ({ - path: '/race-file.txt', + path: '/workspace/race-file.txt', created: true, }), text: async () => 'Created', @@ -559,11 +578,11 @@ describe('OpenClaw Workspace Access Control', () => { request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/race-file.txt', content: 'Request 1', encoding: 'utf8' }), + .send({ path: '/workspace/race-file.txt', content: 'Request 1', encoding: 'utf8' }), request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/race-file.txt', content: 'Request 2', encoding: 'utf8' }), + .send({ path: '/workspace/race-file.txt', content: 'Request 2', encoding: 'utf8' }), ]); // Both requests pass existence check (404), but workspace service should handle atomicity @@ -596,7 +615,7 @@ describe('OpenClaw Workspace Access Control', () => { ok: true, status: 201, json: async () => ({ - path: '/service-error-file.txt', + path: '/workspace/service-error-file.txt', created: true, }), text: async () => 'Created', @@ -607,7 +626,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/service-error-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace/service-error-file.txt', content: 'Hello', encoding: 'utf8' }); // Should proceed with creation despite non-404 error expect(response.status).toBe(201); @@ -625,7 +644,7 @@ describe('OpenClaw Workspace Access Control', () => { const response = await request(app) .post('/api/v1/openclaw/workspace/files') .set('Authorization', `Bearer ${token}`) - .send({ path: '/error-file.txt', content: 'Hello', encoding: 'utf8' }); + .send({ path: '/workspace/error-file.txt', content: 'Hello', encoding: 'utf8' }); // Should propagate the error (makeOpenClawRequest wraps network errors as 503) expect(response.status).toBe(503); @@ -772,7 +791,7 @@ describe('OpenClaw Workspace Access Control', () => { }); describe('GET /api/v1/openclaw/agents mapping', () => { - it('maps missing workspace for default/main agents to root and others to /workspace-', async () => { + it('maps missing workspace for default/main agents to /workspace and others to /workspace-', async () => { const token = getToken('admin-id', 'admin'); global.fetch = jest.fn().mockResolvedValue({ @@ -820,10 +839,10 @@ describe('OpenClaw Workspace Access Control', () => { const helperAgent = response.body.data.find((a) => a.id === 'helper'); const explicitWorkspaceAgent = response.body.data.find((a) => a.id === 'clawboard-worker'); - expect(mainAgent.workspace).toBe('/'); - expect(defaultAgent.workspace).toBe('/'); + expect(mainAgent.workspace).toBe('/workspace'); + expect(defaultAgent.workspace).toBe('/workspace'); expect(helperAgent.workspace).toBe('/workspace-helper'); - expect(explicitWorkspaceAgent.workspace).toBe('/workspace-clawboard-worker'); + expect(explicitWorkspaceAgent.workspace).toBe('/workspace/workspace-clawboard-worker'); }); }); @@ -845,7 +864,7 @@ describe('OpenClaw Workspace Access Control', () => { expect(Array.isArray(response.body.data)).toBe(true); expect(response.body.data).toHaveLength(2); expect(response.body.data[0].id).toBe('coo'); - expect(response.body.data[0].workspace).toBe('/'); + expect(response.body.data[0].workspace).toBe('/workspace'); expect(response.body.data[1].id).toBe('archived'); expect(response.body.data[1].workspace).toBe('/_archived_workspace_main'); }); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index ce59e31..ff0719a 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -19,6 +19,10 @@ const BUILTIN_OPENCLAW_REMAP_PREFIXES = [ '/home/node/.openclaw', '~/.openclaw', ]; +const MAIN_WORKSPACE_REMAP_PREFIXES = new Set([ + '/home/node/.openclaw/workspace', + '/~/.openclaw/workspace', +]); // Auth middleware - require valid JWT const requireAuth = (req, res, next) => { @@ -88,12 +92,20 @@ function remapWorkspacePathPrefixes(workspacePath) { const prefixes = getOpenClawPathRemapPrefixes(); for (const prefix of prefixes) { + const isMainWorkspacePrefix = MAIN_WORKSPACE_REMAP_PREFIXES.has(prefix); + if (workspacePath === prefix) { + if (isMainWorkspacePrefix) { + return '/workspace'; + } return '/'; } if (workspacePath.startsWith(`${prefix}/`)) { const remapped = workspacePath.substring(prefix.length); + if (isMainWorkspacePrefix) { + return normalizeAndValidateWorkspacePath(`/workspace${remapped}`); + } return normalizeAndValidateWorkspacePath(remapped); } } @@ -121,7 +133,7 @@ function resolveAgentWorkspacePath(agent) { } if (agent?.default === true || agent?.id === 'main') { - return '/'; + return '/workspace'; } const agentId = typeof agent?.id === 'string' && agent.id.trim() ? agent.id.trim() : 'agent'; @@ -134,8 +146,8 @@ function resolveAgentWorkspacePath(agent) { * @returns {boolean} - True if path is allowed */ function isAllowedWorkspacePath(workspacePath) { - // Allow root - if (workspacePath === '/') return true; + // Allow canonical main workspace virtual path + if (workspacePath === '/workspace' || workspacePath.startsWith('/workspace/')) return true; // Allow system config files if (workspacePath === '/openclaw.json' || workspacePath === '/org-chart.json') return true; @@ -157,7 +169,7 @@ function isAllowedWorkspacePath(workspacePath) { ) return true; - // Allow agent workspaces (canonical main workspace is "/"; no "/workspace" alias) + // Allow agent workspaces if (workspacePath.startsWith('/workspace-') || /^\/workspace-[a-z]+(\/|$)/.test(workspacePath)) return true; @@ -185,7 +197,7 @@ function toUpdatedAtMs(val) { // List workspace files (all authenticated users can view metadata) router.get('/workspace/files', requireAuth, async (req, res, next) => { try { - const { path: inputPath = '/', recursive = 'false' } = req.query; + const { path: inputPath = '/workspace', recursive = 'false' } = req.query; const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed @@ -285,6 +297,17 @@ router.post('/workspace/files', requireAuth, requireAdmin, async (req, res, next const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); + // Validate path is allowed + if (!isAllowedWorkspacePath(workspacePath)) { + return res.status(403).json({ + error: { + message: 'Access denied: Path not allowed', + status: 403, + code: 'PATH_NOT_ALLOWED', + }, + }); + } + // Restrict system config files to admin/owner only (exclude 'agent' role) const isSystemConfigFile = workspacePath === '/openclaw.json' || workspacePath === '/org-chart.json'; @@ -597,7 +620,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { title: null, description: 'Operations and workflow management', icon: '📊', - workspace: '/', + workspace: '/workspace', isDefault: true, }, ]; @@ -645,7 +668,7 @@ router.get('/agents', requireAuth, async (req, res, next) => { label: 'Chief Operating Officer', description: 'Operations and workflow management', icon: '📊', - workspace: '/', + workspace: '/workspace', isDefault: true, }, {