diff --git a/.env.example b/.env.example index 4a44eb9..ee67611 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +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: 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 2699fa9..dc03d16 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,13 @@ 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` 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. > **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..bd311a4 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` | `''` | 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 9156459..a27a8b7 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -75,6 +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 +# 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 6615ceb..4cc9111 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 || ''; + }, 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..30d66f2 100644 --- a/src/routes/__tests__/openclaw.integration.test.js +++ b/src/routes/__tests__/openclaw.integration.test.js @@ -55,21 +55,25 @@ 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 = ''; }); afterAll(() => { // Restore original fetch global.fetch = originalFetch; delete process.env.OPENCLAW_WORKSPACE_URL; + delete process.env.OPENCLAW_PATH_REMAP_PREFIXES; }); beforeEach(() => { + process.env.OPENCLAW_PATH_REMAP_PREFIXES = ''; + // Mock successful OpenClaw responses global.fetch = jest.fn().mockResolvedValue({ 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', }); @@ -82,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), ); }); @@ -98,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(); @@ -110,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(); @@ -120,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'); @@ -131,12 +135,143 @@ 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'); 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/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 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 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%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%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 unsupported absolute-looking paths', 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(); + }); + + 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/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'); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); describe('GET /api/v1/openclaw/workspace/files/content', () => { @@ -161,7 +296,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(); @@ -178,19 +313,34 @@ 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(); }); + 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'); 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'); @@ -200,7 +350,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'); @@ -241,7 +391,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', @@ -252,7 +402,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(); @@ -279,7 +429,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', @@ -290,7 +440,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(); @@ -302,7 +452,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'); @@ -330,7 +480,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', @@ -341,7 +491,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(); @@ -377,7 +527,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(); @@ -411,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', @@ -424,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 @@ -461,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', @@ -472,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); @@ -490,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); @@ -507,7 +661,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', @@ -520,7 +674,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(); @@ -533,7 +687,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(); @@ -545,7 +699,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'); @@ -570,7 +724,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(); @@ -582,7 +736,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); }); @@ -593,7 +747,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'); @@ -635,4 +789,84 @@ describe('OpenClaw Workspace Access Control', () => { expect(global.fetch).not.toHaveBeenCalled(); }); }); + + describe('GET /api/v1/openclaw/agents mapping', () => { + 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({ + 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/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('/workspace'); + expect(defaultAgent.workspace).toBe('/workspace'); + expect(helperAgent.workspace).toBe('/workspace-helper'); + expect(explicitWorkspaceAgent.workspace).toBe('/workspace/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'); + + 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('/_archived_workspace_main'); + }); + }); }); diff --git a/src/routes/openclaw.js b/src/routes/openclaw.js index 3e1d8a7..ff0719a 100644 --- a/src/routes/openclaw.js +++ b/src/routes/openclaw.js @@ -13,6 +13,17 @@ 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', +]; +const MAIN_WORKSPACE_REMAP_PREFIXES = new Set([ + '/home/node/.openclaw/workspace', + '/~/.openclaw/workspace', +]); + // Auth middleware - require valid JWT const requireAuth = (req, res, next) => { const authHeader = req.headers.authorization; @@ -62,14 +73,81 @@ function normalizeAndValidateWorkspacePath(inputPath) { return normalized; } +function getOpenClawPathRemapPrefixes() { + 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) { + 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); + } + } + + return workspacePath; +} + +function normalizeRemapAndValidateWorkspacePath(inputPath) { + const normalizedPath = normalizeAndValidateWorkspacePath(inputPath); + return remapWorkspacePathPrefixes(normalizedPath); +} + +function resolveAgentWorkspacePath(agent) { + if (typeof agent?.workspace === 'string' && agent.workspace.trim()) { + 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') { + return '/workspace'; + } + + 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 * @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; @@ -84,8 +162,14 @@ 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)) return true; @@ -113,8 +197,8 @@ 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 workspacePath = normalizeAndValidateWorkspacePath(inputPath); + const { path: inputPath = '/workspace', recursive = 'false' } = req.query; + const workspacePath = normalizeRemapAndValidateWorkspacePath(inputPath); // Validate path is allowed if (!isAllowedWorkspacePath(workspacePath)) { @@ -157,7 +241,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)) { @@ -211,7 +295,18 @@ router.post('/workspace/files', requireAuth, requireAdmin, async (req, res, next }); } - const workspacePath = normalizeAndValidateWorkspacePath(inputPath); + 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 = @@ -316,7 +411,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)) { @@ -391,7 +486,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)) { @@ -513,7 +608,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, })) : [ @@ -525,7 +620,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, }, ]; @@ -573,7 +668,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, }, { @@ -582,7 +677,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, }, ], @@ -4198,7 +4293,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/')) {