Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9e4cb1b
fix(web-ui): validate provider modal input
awsldev Apr 8, 2026
8bb9a2a
test(web-ui): relax parity baseline drift assertions
awsldev Apr 8, 2026
563f0ee
fix(web-ui): harden usage tab rendering
awsldev Apr 8, 2026
1f28abe
fix(web-ui): use production vue runtime
awsldev Apr 8, 2026
47b291f
docs(readme): update reset command examples
awsldev Apr 8, 2026
915b764
fix(usage): skip session detail loading for charts
awsldev Apr 8, 2026
f16a61d
fix(provider): persist normalized urls on add
awsldev Apr 8, 2026
657e95c
fix(reset): silence git repo probe output
awsldev Apr 8, 2026
6b441c0
fix(usage): decouple charts from heavy session loads
awsldev Apr 8, 2026
48ac808
fix(openclaw): sync default config with live file
awsldev Apr 8, 2026
1a1ff13
fix(openclaw): read provider fields from legacy shapes
awsldev Apr 8, 2026
bb27952
fix(openclaw): fallback to sole provider config
awsldev Apr 8, 2026
a06f44b
fix(openclaw): show structured provider refs in quick form
awsldev Apr 8, 2026
9096a81
refactor(openclaw): remove add-config button from tab
awsldev Apr 8, 2026
07be648
fix(openclaw): align config reading with official secret fields
awsldev Apr 8, 2026
a1fd9a8
fix(openclaw): normalize provider lookup in quick form
awsldev Apr 8, 2026
62e1b24
fix(openclaw): surface auth profile fallback in quick form
awsldev Apr 8, 2026
50381f8
fix(openclaw): make external auth values visible and editable
awsldev Apr 8, 2026
265bfe4
perf(sessions): defer detail hydration on tab switch
awsldev Apr 8, 2026
2e82f22
perf(sessions): batch session list rendering on demand
awsldev Apr 8, 2026
7621627
fix(cli): remove duplicate isPlainObject helper
awsldev Apr 9, 2026
9eab383
fix(web-ui): restore session browser runtime
ymkiux Apr 9, 2026
4dff290
fix(web-ui): align session browser implementation
ymkiux Apr 9, 2026
b9e6da2
test(web-ui): sync parity drift expectations
ymkiux Apr 9, 2026
de4ac6a
fix(openclaw): redact secrets and harden session usage
ymkiux Apr 9, 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
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,11 @@ npm start run --no-browser

```bash
npm run reset
npm run reset -- 79
npm run reset 79
```

- `npm run reset`: prompt for a PR number; leave it blank to return to default `origin/main`
- `npm run reset -- 79`: sync directly to the latest head snapshot of PR `#79`
- `npm run reset 79`: sync directly to the latest head snapshot of PR `#79`
- The script also handles local branch switching, workspace cleanup, untracked file cleanup, and final state validation

## Command Reference
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ npm start run --no-browser

```bash
npm run reset
npm run reset -- 79
npm run reset 79
```

- `npm run reset`:交互输入 PR 编号;留空则回到默认 `origin/main`
- `npm run reset -- 79`:直接同步到 PR `#79` 的最新 head 快照
- `npm run reset 79`:直接同步到 PR `#79` 的最新 head 快照
- 脚本会自动完成本地分支切换、工作区清理、未跟踪文件清理与最终状态校验

## 命令速查
Expand Down
24 changes: 20 additions & 4 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3449,12 +3449,16 @@ function addProviderToConfig(params = {}) {
const url = typeof params.url === 'string' ? params.url.trim() : '';
const key = typeof params.key === 'string' ? params.key.trim() : '';
const allowManaged = !!params.allowManaged;
const normalizedUrl = normalizeBaseUrl(url);

if (!name) return { error: '名称不能为空' };
if (!url) return { error: 'URL 不能为空' };
if (!isValidProviderName(name)) {
return { error: '名称仅支持字母/数字/._-' };
}
if (!isValidHttpUrl(normalizedUrl)) {
return { error: 'URL 仅支持 http/https' };
}
if (isReservedProviderNameForCreation(name)) {
return { error: 'local provider 为系统保留名称,不可新增' };
}
Expand Down Expand Up @@ -3496,7 +3500,7 @@ function addProviderToConfig(params = {}) {

const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
const safeName = escapeTomlBasicString(name);
const safeUrl = escapeTomlBasicString(url);
const safeUrl = escapeTomlBasicString(normalizedUrl);
const safeKey = escapeTomlBasicString(key);
const block = [
buildModelProviderTableHeader(name),
Expand Down Expand Up @@ -3533,6 +3537,9 @@ function updateProviderInConfig(params = {}) {
if (!url && key === undefined) {
return { error: 'URL 或密钥至少填写一项' };
}
if (url && !isValidHttpUrl(normalizeBaseUrl(url))) {
return { error: 'URL 仅支持 http/https' };
}
if (isNonEditableProvider(name) && !allowManaged) {
if (isDefaultLocalProvider(name)) {
return { error: 'local provider 为系统保留项,不可编辑' };
Expand Down Expand Up @@ -8228,7 +8235,7 @@ function cmdUseModel(modelName, silent = false) {
// 添加提供商
function cmdAdd(name, baseUrl, apiKey, silent = false) {
const providerName = typeof name === 'string' ? name.trim() : '';
const providerBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
const providerBaseUrl = normalizeBaseUrl(baseUrl);

if (!providerName || !providerBaseUrl) {
if (!silent) {
Expand All @@ -8250,6 +8257,10 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
if (!silent) console.error('错误: codexmate-proxy 为保留名称,不可手动添加');
throw new Error('codexmate-proxy 为保留名称,不可手动添加');
}
if (!isValidHttpUrl(providerBaseUrl)) {
if (!silent) console.error('错误: URL 仅支持 http/https');
throw new Error('URL 仅支持 http/https');
}

const config = readConfig();
if (config.model_providers && config.model_providers[providerName]) {
Expand Down Expand Up @@ -8307,6 +8318,7 @@ function cmdDelete(name, silent = false) {
// 更新提供商
function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
const allowManaged = !!(options && options.allowManaged);
const normalizedBaseUrl = baseUrl === undefined ? undefined : normalizeBaseUrl(baseUrl);
if (!name) {
if (!silent) console.error('错误: 提供商名称必填');
throw new Error('提供商名称必填');
Expand Down Expand Up @@ -8335,6 +8347,10 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
if (!silent) console.error('错误: 无法找到提供商配置块');
throw new Error('无法找到提供商配置块');
}
if (normalizedBaseUrl !== undefined && !isValidHttpUrl(normalizedBaseUrl)) {
if (!silent) console.error('错误: URL 仅支持 http/https');
throw new Error('URL 仅支持 http/https');
}

const replaceTomlStringField = (block, fieldName, rawValue) => {
const safeValue = escapeTomlBasicString(rawValue);
Expand Down Expand Up @@ -8460,8 +8476,8 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
for (const range of sorted) {
const providerBlock = newContent.slice(range.start, range.end);
let updatedBlock = providerBlock;
if (baseUrl) {
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', baseUrl);
if (normalizedBaseUrl) {
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', normalizedBaseUrl);
}
if (apiKey !== undefined) {
updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', apiKey);
Expand Down
2 changes: 1 addition & 1 deletion cmd/reset-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function promptForPrNumber({ stdin = process.stdin, stdout = process.stdout } =

async function main({ argv = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout } = {}) {
try {
run('git rev-parse --is-inside-work-tree');
run('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
} catch (err) {
console.error('Not inside a git repository.');
process.exit(1);
Expand Down
13 changes: 13 additions & 0 deletions res/vue.global.prod.js

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions tests/e2e/test-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,16 @@ preferred_auth_method = "shadow-key"
assert(apiPathsInvalid.error, 'list-session-paths should fail for invalid source');

// ========== Add Provider Tests ==========
const addProvider = await api('add-provider', { name: 'e2e-api', url: mockProviderUrl, key: 'sk-e2e-api' });
const addProviderInputUrl = `${mockProviderUrl}/`;
const addProvider = await api('add-provider', { name: 'e2e-api', url: addProviderInputUrl, key: 'sk-e2e-api' });
assert(addProvider.success === true, 'add-provider failed');

const apiListAfterAdd = await api('list');
assert(Array.isArray(apiListAfterAdd.providers) && apiListAfterAdd.providers.some(p => p.name === 'e2e-api'), 'add-provider not reflected in list');
const addedProvider = Array.isArray(apiListAfterAdd.providers)
? apiListAfterAdd.providers.find((p) => p && p.name === 'e2e-api')
: null;
assert(addedProvider, 'add-provider not reflected in list');
assert(addedProvider.url === mockProviderUrl, 'add-provider should persist normalized provider url');

const addProviderEmptyName = await api('add-provider', { name: '', url: mockProviderUrl });
assert(addProviderEmptyName.error, 'add-provider should reject empty name');
Expand All @@ -311,11 +316,19 @@ preferred_auth_method = "shadow-key"

const addProviderInvalidName = await api('add-provider', { name: 'bad name', url: mockProviderUrl });
assert(addProviderInvalidName.error, 'add-provider should reject invalid provider name');
const addProviderInvalidUrl = await api('add-provider', { name: 'bad-url', url: 'not-a-url' });
assert(addProviderInvalidUrl.error, 'add-provider should reject invalid provider url');
const cliAddInvalidUrl = runSync(node, [cliPath, 'add', 'cli-bad-url', 'not-a-url'], { env });
assert(cliAddInvalidUrl.status !== 0, 'cli add should reject invalid provider url');
const apiListAfterInvalidName = await api('list');
assert(
!apiListAfterInvalidName.providers.some((item) => item && item.name === 'bad name'),
'add-provider invalid name should not pollute provider list'
);
assert(
!apiListAfterInvalidName.providers.some((item) => item && item.name === 'bad-url'),
'add-provider invalid url should not pollute provider list'
);
const apiStatusAfterInvalidName = await api('status');
assert(apiStatusAfterInvalidName.provider, 'status should remain readable after invalid add-provider');

Expand Down Expand Up @@ -1356,6 +1369,8 @@ preferred_auth_method = "shadow-key"

// ========== Update Provider Tests ==========
const updatedUrl = `${mockProviderUrl}/v2`;
const updateProviderInvalidUrl = await api('update-provider', { name: 'e2e-api', url: 'ftp://bad.example.com' });
assert(updateProviderInvalidUrl.error, 'update-provider should reject invalid provider url');
const updateProvider = await api('update-provider', { name: 'e2e-api', url: updatedUrl, key: 'sk-e2e-api-upd' });
assert(updateProvider.success === true, 'update-provider failed');

Expand Down
6 changes: 6 additions & 0 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /isProviderConfigMode/);
assert.match(html, /provider-fast-switch-select/);
assert.match(html, /forceCompactLayout/);
assert.match(html, /<script src="\/res\/vue\.global\.prod\.js"><\/script>/);
assert.match(html, /quickSwitchProvider\(\$event\.target\.value\)/);
assert.match(html, /onMainTabPointerDown\('sessions', \$event\)/);
assert.match(html, /onConfigTabPointerDown\('codex', \$event\)/);
Expand Down Expand Up @@ -207,6 +208,9 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(usagePanel, /sessionsUsageTimeRange === '30d'/);
assert.match(usagePanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /sessionUsageCharts\.buckets/);
assert.doesNotMatch(usagePanel, /sessionUsageCharts\.topPaths\[0\]\?\.count/);
assert.doesNotMatch(html, /sessionUsageSummaryCards\[0\]\?\.value/);
assert.doesNotMatch(html, /sessionUsageSummaryCards\[1\]\?\.value/);
assert.match(html, /class="pin-icon"/);
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
Expand All @@ -229,6 +233,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /<button class="card-action-btn delete"[^>]*@click="deleteClaudeConfig\(name\)"[^>]*:aria-label="`Delete Claude config \$\{name\}`"[^>]*title="删除">/);
assert.match(html, /<button class="card-action-btn"[^>]*@click="copyClaudeShareCommand\(name\)"[^>]*disabled[^>]*>/);
assert.match(html, /<button class="card-action-btn"[^>]*@click="openOpenclawEditModal\(name\)"[^>]*:aria-label="`Edit OpenClaw config \$\{name\}`"[^>]*title="编辑">/);
assert.match(html, /<div class="docs-command-row">[\s\S]*<code class="install-command">\{\{ target\.command \}\}<\/code>[\s\S]*<button type="button" class="btn-mini docs-copy-btn"/);
assert.match(html, /<button class="card-action-btn delete"[^>]*@click="deleteOpenclawConfig\(name\)"[^>]*:aria-label="`Delete OpenClaw config \$\{name\}`"[^>]*title="删除">/);
assert.match(modalsBasic, /<div v-if="showAddModal" class="modal-overlay" @click\.self="closeAddModal">/);
assert.match(modalsBasic, /<div v-if="showModelModal" class="modal-overlay" @click\.self="closeModelModal">/);
Expand Down Expand Up @@ -288,6 +293,7 @@ test('web ui script defines provider mode metadata for codex only', () => {
assert.match(appScript, /this\.switchMainTab\('config'\);/);
assert.match(appScript, /if \(this\.mainTab === 'config'\) {/);
assert.match(appScript, /this\.clearMainTabSwitchIntent\('config'\);/);
assert.match(appScript, /__mainTabSwitchState:\s*\{[\s\S]*intent:\s*''[\s\S]*pendingTarget:\s*''[\s\S]*pendingConfigMode:\s*''[\s\S]*ticket:\s*0[\s\S]*\}/);
assert.match(appScript, /setMainTabSwitchIntent\(tab\)/);
assert.match(appScript, /ensureMainTabSwitchState\(\)/);
assert.match(appScript, /ensureImmediateNavDomState\(\)/);
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/provider-switch-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ test('updateProvider keeps edit modal open when request throws', async () => {
assert.strictEqual(context.showEditModal, true);
assert.deepStrictEqual(context.editingProvider, {
name: 'alpha',
url: ' https://api.example.com/v1 ',
url: 'https://api.example.com/v1',
key: '',
readOnly: false,
nonEditable: false
Expand Down
129 changes: 129 additions & 0 deletions tests/unit/providers-validation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from 'assert';
import { createProvidersMethods } from '../../web-ui/modules/app.methods.providers.mjs';

function createContext(overrides = {}, apiImpl = async () => ({ success: true })) {
const messages = [];
const loadAllCalls = [];
const methods = createProvidersMethods({ api: apiImpl });
const context = {
providersList: [],
codexAuthProfiles: [],
showAddModal: true,
showEditModal: false,
resetConfigLoading: false,
newProvider: { name: '', url: '', key: '' },
editingProvider: { name: '', url: '', key: '', readOnly: false, nonEditable: false },
claudeConfigs: {},
showMessage(text, type) {
messages.push({ text: String(text), type: type || 'info' });
},
async loadAll() {
loadAllCalls.push('loadAll');
},
...methods,
...overrides
};
return { context, messages, loadAllCalls };
}

test('provider validation rejects invalid add-provider fields before submit', async () => {
const apiCalls = [];
const { context, messages } = createContext({
newProvider: {
name: 'bad name',
url: 'not-a-url',
key: 'sk-test'
}
}, async (action, params) => {
apiCalls.push({ action, params });
return { success: true };
});

assert.strictEqual(context.canSubmitProvider('add'), false);
assert.strictEqual(context.providerFieldError('add', 'name'), '名称仅支持字母/数字/._-');
assert.strictEqual(context.providerFieldError('add', 'url'), 'URL 仅支持 http/https');

await context.addProvider();

assert.deepStrictEqual(apiCalls, []);
assert.strictEqual(messages.length, 1);
assert.deepStrictEqual(messages[0], {
text: '名称仅支持字母/数字/._-',
type: 'error'
});
});

test('addProvider normalizes trimmed values and submits sanitized payload', async () => {
const apiCalls = [];
const { context, messages, loadAllCalls } = createContext({
providersList: [{ name: 'alpha', url: 'https://alpha.example.com/v1', hasKey: true }],
newProvider: {
name: ' beta.provider ',
url: ' https://api.example.com/v1/ ',
key: ' sk-live '
}
}, async (action, params) => {
apiCalls.push({ action, params });
return { success: true };
});

await context.addProvider();

assert.deepStrictEqual(apiCalls, [{
action: 'add-provider',
params: {
name: 'beta.provider',
url: 'https://api.example.com/v1',
key: ' sk-live '
}
}]);
assert.strictEqual(context.showAddModal, false);
assert.deepStrictEqual(context.newProvider, { name: '', url: '', key: '' });
assert.deepStrictEqual(loadAllCalls, ['loadAll']);
assert.strictEqual(messages.length, 1);
assert.deepStrictEqual(messages[0], {
text: '操作成功',
type: 'success'
});
});

test('updateProvider blocks invalid edit URL and skips api call', async () => {
const apiCalls = [];
const { context, messages } = createContext({
editingProvider: {
name: 'alpha',
url: 'ftp://api.example.com',
key: '',
readOnly: false,
nonEditable: false
}
}, async (action, params) => {
apiCalls.push({ action, params });
return { success: true };
});

assert.strictEqual(context.canSubmitProvider('edit'), false);
assert.strictEqual(context.providerFieldError('edit', 'url'), 'URL 仅支持 http/https');

await context.updateProvider();

assert.deepStrictEqual(apiCalls, []);
assert.strictEqual(messages.length, 1);
assert.deepStrictEqual(messages[0], {
text: 'URL 仅支持 http/https',
type: 'error'
});
});

test('provider validation rejects reserved proxy name on add', () => {
const { context } = createContext({
newProvider: {
name: 'codexmate-proxy',
url: 'https://api.example.com/v1',
key: ''
}
});

assert.strictEqual(context.providerFieldError('add', 'name'), 'codexmate-proxy 为保留名称,不可手动添加');
assert.strictEqual(context.canSubmitProvider('add'), false);
});
5 changes: 5 additions & 0 deletions tests/unit/reset-main.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,8 @@ test('package.json exposes reset command for reset-main workflow', () => {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8').replace(/^\uFEFF/u, ''));
assert.strictEqual(packageJson.scripts.reset, 'node cmd/reset-main.js');
});

test('git repository probe stays silent during reset startup', () => {
const content = fs.readFileSync(scriptPath, 'utf-8');
assert.match(content, /run\('git rev-parse --is-inside-work-tree', \{ stdio: 'ignore' \}\);/);
});
1 change: 1 addition & 0 deletions tests/unit/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'unzip-ext.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'providers-validation.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'provider-switch-regression.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'codex-proxy-options.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs')));
Expand Down
Loading
Loading