Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
26d3eeb
refactor: split web ui modules and harden provider switching
SurviveM Apr 4, 2026
a5c327f
fix: restore node 18 web ui parity fixture
SurviveM Apr 4, 2026
3156c6b
fix: address web ui regression gaps
SurviveM Apr 4, 2026
a7eb34d
fix: harden web ui parity and modal regressions
SurviveM Apr 4, 2026
974fe04
test: stabilize web ui parity baseline
SurviveM Apr 4, 2026
c404349
fix: preserve provider state on edit and failed switch
SurviveM Apr 4, 2026
bf27523
fix: keep provider edit draft on update errors
SurviveM Apr 4, 2026
b359875
fix: harden provider inputs and card accessibility
SurviveM Apr 4, 2026
57d76d9
fix: harden api response error handling
SurviveM Apr 5, 2026
29c7da9
fix: tighten web ui startup and bundle guards
SurviveM Apr 5, 2026
e1e8c35
fix: harden web ui regression guards
SurviveM Apr 5, 2026
a73ad9e
fix: align openclaw modal busy state
SurviveM Apr 5, 2026
02c2d55
fix: stabilize web ui session and claude flows
SurviveM Apr 5, 2026
80d5f80
fix: stabilize openclaw modal persistence flow
SurviveM Apr 5, 2026
6358e7c
fix: guard overlapping openclaw modal actions
SurviveM Apr 5, 2026
cb05e7d
fix: harden config and agents modal busy guards
SurviveM Apr 5, 2026
9851d49
fix: reject invalid session standalone urls
SurviveM Apr 5, 2026
17d2424
fix: close remaining web ui review regressions
SurviveM Apr 5, 2026
d8dd962
fix: finish remaining web ui review follow-ups
SurviveM Apr 5, 2026
35a8be2
fix: preserve web ui parity contracts
SurviveM Apr 5, 2026
d1b924b
fix: harden web ui parity regressions
SurviveM Apr 5, 2026
2347988
ci: refine coderabbit review prompt
SurviveM Apr 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
163 changes: 155 additions & 8 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const {
validateWorkflowDefinition,
executeWorkflowDefinition
} = require('./lib/workflow-engine');
const {
readBundledWebUiCss,
readBundledWebUiHtml,
readExecutableBundledJavaScriptModule,
readExecutableBundledWebUiScript
} = require('./web-ui/source-bundle.cjs');
Comment thread
SurviveM marked this conversation as resolved.

const DEFAULT_WEB_PORT = 3737;
const DEFAULT_WEB_HOST = '0.0.0.0';
Expand Down Expand Up @@ -9945,10 +9951,11 @@ function formatHostForUrl(host) {
return value;
}

// #region watchPathsForRestart
function watchPathsForRestart(targets, onChange) {
const disposers = [];
const debounceMs = 300;
let timer = null;
const watcherEntries = new Map();

const trigger = (info) => {
if (timer) clearTimeout(timer);
Expand All @@ -9958,35 +9965,133 @@ function watchPathsForRestart(targets, onChange) {
}, debounceMs);
};

const addWatcher = (target, recursive) => {
const closeWatcher = (watchKey) => {
const entry = watcherEntries.get(watchKey);
if (!entry) return;
watcherEntries.delete(watchKey);
try {
entry.watcher.close();
} catch (_) {}
};

const listDirectoryTree = (rootDir) => {
const queue = [rootDir];
const directories = [];
const seen = new Set();
while (queue.length) {
const current = queue.shift();
if (!current || seen.has(current) || !fs.existsSync(current)) {
continue;
}
seen.add(current);
let stat = null;
try {
stat = fs.statSync(current);
} catch (_) {
continue;
}
if (!stat || !stat.isDirectory()) {
continue;
}
directories.push(current);
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (_) {
continue;
}
for (const entry of entries) {
if (entry && typeof entry.isDirectory === 'function' && entry.isDirectory()) {
queue.push(path.join(current, entry.name));
}
}
}
return directories;
};

const isSameOrNestedPath = (candidate, rootDir) => {
return candidate === rootDir || candidate.startsWith(`${rootDir}${path.sep}`);
};

const addWatcher = (target, recursive, isDirectory = false) => {
if (!fs.existsSync(target)) return;
const watchKey = `${recursive ? 'recursive' : 'plain'}:${target}`;
if (watcherEntries.has(watchKey)) {
return true;
}
try {
const watcher = fs.watch(target, { recursive }, (eventType, filename) => {
if (isDirectory && !recursive && eventType === 'rename') {
syncDirectoryTree(target);
}
if (!filename) return;
const lower = filename.toLowerCase();
if (!(/\.(html|js|mjs|css)$/.test(lower))) return;
trigger({ target, eventType, filename });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
disposers.push(() => watcher.close());
watcherEntries.set(watchKey, {
watcher,
target,
recursive,
isDirectory
});
return true;
} catch (e) {
return false;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const syncDirectoryTree = (rootDir) => {
const directories = listDirectoryTree(rootDir);
const existingDirectorySet = new Set(directories);
for (const [watchKey, entry] of Array.from(watcherEntries.entries())) {
if (!entry.isDirectory || entry.recursive) {
continue;
}
if (!isSameOrNestedPath(entry.target, rootDir)) {
continue;
}
if (!existingDirectorySet.has(entry.target)) {
closeWatcher(watchKey);
}
}
for (const directory of directories) {
addWatcher(directory, false, true);
}
};

for (const target of targets) {
const ok = addWatcher(target, true);
if (!fs.existsSync(target)) continue;
let stat = null;
try {
stat = fs.statSync(target);
} catch (_) {
continue;
}
if (stat && stat.isDirectory()) {
const ok = addWatcher(target, true, true);
if (!ok) {
syncDirectoryTree(target);
}
continue;
}
const ok = addWatcher(target, true, false);
if (!ok) {
addWatcher(target, false);
addWatcher(target, false, false);
}
}

return () => {
for (const dispose of disposers) {
try { dispose(); } catch (_) {}
if (timer) {
clearTimeout(timer);
timer = null;
}
for (const watchKey of Array.from(watcherEntries.keys())) {
closeWatcher(watchKey);
}
};
}
// #endregion watchPathsForRestart

function writeJsonResponse(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2);
Expand Down Expand Up @@ -10131,6 +10236,32 @@ async function handleImportSkillsZipUpload(req, res, options = {}) {
}
}

const PUBLIC_WEB_UI_DYNAMIC_ASSETS = new Map([
['app.js', {
mime: 'application/javascript; charset=utf-8',
reader: readExecutableBundledWebUiScript
}],
['index.html', {
mime: 'text/html; charset=utf-8',
reader: readBundledWebUiHtml
}],
['logic.mjs', {
mime: 'application/javascript; charset=utf-8',
reader: readExecutableBundledJavaScriptModule
}],
['styles.css', {
mime: 'text/css; charset=utf-8',
reader: readBundledWebUiCss
}]
]);

const PUBLIC_WEB_UI_STATIC_ASSETS = new Set([
'modules/config-mode.computed.mjs',
'modules/skills.computed.mjs',
'modules/skills.methods.mjs',
'session-helpers.mjs'
]);

function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) {
const connections = new Set();

Expand Down Expand Up @@ -10562,6 +10693,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.end(errorBody, 'utf-8');
}
});
} else if (requestPath === '/web-ui') {
const html = readBundledWebUiHtml(htmlPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
Comment thread
SurviveM marked this conversation as resolved.
Outdated
} else if (requestPath.startsWith('/web-ui/')) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
const filePath = path.join(__dirname, normalized);
Expand All @@ -10570,11 +10705,23 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.end('Forbidden');
return;
}
const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/');
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath);
if (dynamicAsset) {
res.writeHead(200, { 'Content-Type': dynamicAsset.mime });
res.end(dynamicAsset.reader(filePath), 'utf-8');
return;
}
if (!PUBLIC_WEB_UI_STATIC_ASSETS.has(relativePath)) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.js' || ext === '.mjs'
? 'application/javascript; charset=utf-8'
Expand Down Expand Up @@ -10642,7 +10789,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
res.writeHead(200, { 'Content-Type': mime });
fs.createReadStream(filePath).pipe(res);
} else {
const html = fs.readFileSync(htmlPath, 'utf-8');
const html = readBundledWebUiHtml(htmlPath);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
}
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const testMessages = require('./test-messages');
const testMcp = require('./test-mcp');
const testWorkflow = require('./test-workflow');
const testInvalidConfig = require('./test-invalid-config');
const testWebUiAssets = require('./test-web-ui-assets');

async function main() {
const realHome = os.homedir();
Expand Down Expand Up @@ -118,6 +119,7 @@ async function main() {
await testMessages(ctx);
await testMcp(ctx);
await testWorkflow(ctx);
await testWebUiAssets(ctx);

} finally {
const waitForExit = new Promise((resolve) => {
Expand Down
124 changes: 124 additions & 0 deletions tests/e2e/test-web-ui-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const http = require('http');
const { assert } = require('./helpers');

function getText(port, requestPath, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port,
path: requestPath,
method: 'GET'
}, (res) => {
let body = '';
res.setEncoding('utf-8');
res.on('data', chunk => body += chunk);
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers || {},
body
});
});
});

req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('Request timeout'));
});
req.end();
});
}

module.exports = async function testWebUiAssets(ctx) {
const { port } = ctx;

const rootPage = await getText(port, '/');
assert(rootPage.statusCode === 200, 'root web ui page should return 200');
assert(
/^text\/html\b/.test(String(rootPage.headers['content-type'] || '')),
'root web ui page should return html content type'
);
assert(rootPage.body.includes('id="panel-market"'), 'root web ui page should inline market panel');
assert(rootPage.body.includes('class="modal modal-wide skills-modal"'), 'root web ui page should inline skills modal');
assert(rootPage.body.includes('src="/web-ui/app.js"'), 'root web ui page should point to the absolute app entry');
assert(!rootPage.body.includes('src="web-ui/app.js"'), 'root web ui page should not use a relative app entry');
assert(!/<!--\s*@include\s+/.test(rootPage.body), 'root web ui page should not leak include directives');

const bundledIndex = await getText(port, '/web-ui/index.html');
assert(bundledIndex.statusCode === 200, '/web-ui/index.html should return 200');
assert(
/^text\/html\b/.test(String(bundledIndex.headers['content-type'] || '')),
'/web-ui/index.html should return html content type'
);
assert(bundledIndex.body.includes('id="settings-panel-trash"'), '/web-ui/index.html should inline settings partials');
assert(bundledIndex.body.includes('src="/web-ui/app.js"'), '/web-ui/index.html should point to the absolute app entry');
assert(!bundledIndex.body.includes('src="web-ui/app.js"'), '/web-ui/index.html should not use a relative app entry');
assert(!/<!--\s*@include\s+/.test(bundledIndex.body), '/web-ui/index.html should not leak include directives');

const bundledIndexWithSlash = await getText(port, '/web-ui/');
assert(bundledIndexWithSlash.statusCode === 404, '/web-ui/ should preserve the legacy 404 contract');
assert(
/^text\/plain\b/.test(String(bundledIndexWithSlash.headers['content-type'] || '')),
'/web-ui/ should preserve plain-text not found semantics'
);

const appEntry = await getText(port, '/web-ui/app.js');
assert(appEntry.statusCode === 200, 'app entry should return 200');
assert(
/^application\/javascript\b/.test(String(appEntry.headers['content-type'] || '')),
'app entry should return javascript content type'
);
assert(appEntry.body.includes('document.addEventListener(\'DOMContentLoaded\''), 'app entry should contain the executable bootstrap');
assert(
!/(?:^|\n)\s*import\s+(?:[\s\S]*?\s+from\s+)?['"]\.[^'"]+['"]\s*;?/.test(appEntry.body),
'app entry should not leak split relative module imports'
);
assert(
!/(?:^|\n)\s*export\s+\*\s+from\s+['"]\.[^'"]+['"]\s*;?/.test(appEntry.body),
'app entry should not leak split re-export directives'
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const logicEntry = await getText(port, '/web-ui/logic.mjs');
assert(logicEntry.statusCode === 200, 'logic entry should return 200');
assert(
/^application\/javascript\b/.test(String(logicEntry.headers['content-type'] || '')),
'logic entry should return javascript content type'
);
assert(
logicEntry.body.includes('export function normalizeClaudeValue'),
'logic entry should preserve named exports for compatibility'
);
assert(
!/(?:^|\n)\s*export\s+\*\s+from\s+['"]\.[^'"]+['"]\s*;?/.test(logicEntry.body),
'logic entry should not leak split re-export directives'
);

const cssAsset = await getText(port, '/web-ui/styles/base-theme.css');
assert(cssAsset.statusCode === 404, 'new split css implementation detail should stay private');
assert(
/^text\/plain\b/.test(String(cssAsset.headers['content-type'] || '')),
'private split css asset should return plain-text not found'
);

const bundledCss = await getText(port, '/web-ui/styles.css');
assert(bundledCss.statusCode === 200, 'styles entry should return 200');
assert(
/^text\/css\b/.test(String(bundledCss.headers['content-type'] || '')),
'styles entry should return css content type'
);
assert(bundledCss.body.includes('--radius-md:'), 'styles entry should include theme variables');
assert(
!/@import\s+url\(['"]\.\/styles\//.test(bundledCss.body),
'styles entry should not leak split css imports'
);

const moduleAsset = await getText(port, '/web-ui/modules/app.constants.mjs');
assert(moduleAsset.statusCode === 404, 'new split module implementation detail should stay private');
assert(
/^text\/plain\b/.test(String(moduleAsset.headers['content-type'] || '')),
'private split module asset should return plain-text not found'
);

const traversal = await getText(port, '/web-ui/../cli.js');
assert(traversal.statusCode === 403, 'path traversal outside web-ui should be forbidden');
Comment on lines +122 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add the percent-encoded traversal case too.

This only covers the literal ../ path. A %2e%2e variant can bypass naive path validation without tripping this regression suite.

🧪 Minimal E2E addition
     const traversal = await getText(port, '/web-ui/../cli.js');
     assert(traversal.statusCode === 403, 'path traversal outside web-ui should be forbidden');
+    const encodedTraversal = await getText(port, '/web-ui/%2e%2e/cli.js');
+    assert(encodedTraversal.statusCode === 403, 'percent-encoded traversal outside web-ui should be forbidden');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const traversal = await getText(port, '/web-ui/../cli.js');
assert(traversal.statusCode === 403, 'path traversal outside web-ui should be forbidden');
const traversal = await getText(port, '/web-ui/../cli.js');
assert(traversal.statusCode === 403, 'path traversal outside web-ui should be forbidden');
const encodedTraversal = await getText(port, '/web-ui/%2e%2e/cli.js');
assert(encodedTraversal.statusCode === 403, 'percent-encoded traversal outside web-ui should be forbidden');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/test-web-ui-assets.js` around lines 122 - 123, Add a second E2E
assertion that tests percent-encoded path traversal: call getText with a
percent-encoded traversal URL (e.g. using "%2e%2e" or "%2e%2e%2f" in place of
"../" under the same '/web-ui' prefix) and assert the response (the traversal
variable) also returns statusCode === 403 with a similar message; update the
test around the existing getText/traversal assertion so both literal "../" and
the "%2e%2e" encoded variant are verified to be forbidden.

};
Loading
Loading