Skip to content

Commit c2c7b4c

Browse files
committed
feat: gate provider config writes
1 parent d8e553a commit c2c7b4c

17 files changed

Lines changed: 1475 additions & 886 deletions

‎cli.js‎

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
213213
const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
214214
const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects');
215215
const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate');
216+
const CODEXMATE_PREFERENCES_FILE = path.join(CODEXMATE_DIR, 'preferences.json');
216217
const CODEXMATE_SESSIONS_DIR = path.join(CODEXMATE_DIR, 'sessions');
217218
const CODEXMATE_DERIVED_SESSIONS_DIR = path.join(CODEXMATE_SESSIONS_DIR, 'derived');
218219
const CODEXMATE_DERIVED_CODEX_DIR = path.join(CODEXMATE_DERIVED_SESSIONS_DIR, 'codex');
@@ -717,6 +718,7 @@ function readConfig() {
717718
}
718719

719720
function writeConfig(content) {
721+
assertToolConfigWriteAllowed('codex');
720722
try {
721723
fs.writeFileSync(CONFIG_FILE, content, 'utf-8');
722724
} catch (e) {
@@ -734,6 +736,7 @@ function readModels() {
734736
}
735737

736738
function writeModels(models) {
739+
assertToolConfigWriteAllowed('codex');
737740
fs.writeFileSync(MODELS_FILE, JSON.stringify(models, null, 2), 'utf-8');
738741
}
739742

@@ -747,10 +750,12 @@ function readCurrentModels() {
747750
}
748751

749752
function writeCurrentModels(data) {
753+
assertToolConfigWriteAllowed('codex');
750754
fs.writeFileSync(CURRENT_MODELS_FILE, JSON.stringify(data, null, 2), 'utf-8');
751755
}
752756

753757
function updateAuthJson(apiKey) {
758+
assertToolConfigWriteAllowed('codex');
754759
let authData = {};
755760
if (fs.existsSync(AUTH_FILE)) {
756761
try {
@@ -766,6 +771,123 @@ function isPlainObject(value) {
766771
return !!value && typeof value === 'object' && !Array.isArray(value);
767772
}
768773

774+
const TOOL_CONFIG_PERMISSION_TARGETS = new Set(['codex', 'claude']);
775+
const TOOL_CONFIG_PERMISSION_DEFAULTS = Object.freeze({ codex: false, claude: false });
776+
777+
function normalizeToolConfigTarget(value) {
778+
const target = typeof value === 'string' ? value.trim().toLowerCase() : '';
779+
return TOOL_CONFIG_PERMISSION_TARGETS.has(target) ? target : '';
780+
}
781+
782+
function normalizeToolConfigPermissions(value) {
783+
const source = isPlainObject(value) ? value : {};
784+
return {
785+
codex: source.codex === true,
786+
claude: source.claude === true
787+
};
788+
}
789+
790+
function readCodexmatePreferences() {
791+
if (!fs.existsSync(CODEXMATE_PREFERENCES_FILE)) return {};
792+
try {
793+
const raw = fs.readFileSync(CODEXMATE_PREFERENCES_FILE, 'utf-8');
794+
const parsed = raw && raw.trim() ? JSON.parse(raw) : {};
795+
return isPlainObject(parsed) ? parsed : {};
796+
} catch (_) {
797+
return {};
798+
}
799+
}
800+
801+
function writeCodexmatePreferences(preferences) {
802+
ensureDir(CODEXMATE_DIR);
803+
writeJsonAtomic(CODEXMATE_PREFERENCES_FILE, isPlainObject(preferences) ? preferences : {});
804+
}
805+
806+
function readToolConfigPermissions() {
807+
const preferences = readCodexmatePreferences();
808+
return normalizeToolConfigPermissions(preferences.toolConfigPermissions || TOOL_CONFIG_PERMISSION_DEFAULTS);
809+
}
810+
811+
function isToolConfigWriteAllowed(target) {
812+
const normalizedTarget = normalizeToolConfigTarget(target);
813+
if (!normalizedTarget) return false;
814+
return readToolConfigPermissions()[normalizedTarget] === true;
815+
}
816+
817+
function buildToolConfigWriteDeniedPayload(target) {
818+
const normalizedTarget = normalizeToolConfigTarget(target) || target || '';
819+
return {
820+
error: '当前为仅浏览,未修改配置。',
821+
errorCode: 'tool-config-write-disabled',
822+
target: normalizedTarget,
823+
permissions: readToolConfigPermissions()
824+
};
825+
}
826+
827+
function assertToolConfigWriteAllowed(target) {
828+
if (isToolConfigWriteAllowed(target)) return;
829+
const payload = buildToolConfigWriteDeniedPayload(target);
830+
const err = new Error(payload.error);
831+
err.code = payload.errorCode;
832+
err.target = payload.target;
833+
throw err;
834+
}
835+
836+
function getApiToolConfigWriteTarget(action) {
837+
const name = typeof action === 'string' ? action.trim() : '';
838+
if (!name) return '';
839+
const codexWriteActions = new Set([
840+
'apply-config-template',
841+
'add-provider',
842+
'update-provider',
843+
'delete-provider',
844+
'reset-config',
845+
'add-model',
846+
'delete-model',
847+
'restore-codex-dir',
848+
'import-config',
849+
'import-auth-profile',
850+
'switch-auth-profile',
851+
'delete-auth-profile',
852+
'proxy-enable-codex-default',
853+
'proxy-apply-provider',
854+
'local-bridge-toggle'
855+
]);
856+
const claudeWriteActions = new Set([
857+
'apply-claude-settings-raw',
858+
'apply-claude-config',
859+
'restore-claude-dir',
860+
'claude-local-bridge-toggle',
861+
'claude-local-bridge-sync-providers'
862+
]);
863+
if (codexWriteActions.has(name)) return 'codex';
864+
if (claudeWriteActions.has(name)) return 'claude';
865+
return '';
866+
}
867+
868+
function setToolConfigPermission(params = {}) {
869+
const target = normalizeToolConfigTarget(params && params.target);
870+
if (!target) return { error: '未知配置对象' };
871+
const preferences = readCodexmatePreferences();
872+
const current = normalizeToolConfigPermissions(preferences.toolConfigPermissions || TOOL_CONFIG_PERMISSION_DEFAULTS);
873+
current[target] = params && params.allowWrite === true;
874+
preferences.toolConfigPermissions = current;
875+
writeCodexmatePreferences(preferences);
876+
877+
let bootstrapNotice = '';
878+
if (target === 'codex' && current.codex) {
879+
const bootstrap = ensureManagedConfigBootstrap({ allowWrite: true });
880+
bootstrapNotice = bootstrap && bootstrap.notice ? bootstrap.notice : '';
881+
}
882+
883+
return {
884+
success: true,
885+
target,
886+
permissions: current,
887+
bootstrapNotice
888+
};
889+
}
890+
769891
const PROVIDER_CONFIG_KEYS = new Set([
770892
'name',
771893
'base_url',
@@ -5669,6 +5791,7 @@ function syncClaudeProvidersToBridgeFile() {
56695791
}
56705792

56715793
function toggleClaudeLocalBridge(params = {}) {
5794+
assertToolConfigWriteAllowed('claude');
56725795
const enable = !!params.enable;
56735796
const settings = readClaudeLocalBridgeSettings();
56745797

@@ -9177,6 +9300,7 @@ function maskKey(key) {
91779300

91789301
// 应用到 Claude Code settings.json(跨平台)
91799302
function applyToClaudeSettings(config = {}) {
9303+
assertToolConfigWriteAllowed('claude');
91809304
try {
91819305
const apiKey = (config.apiKey || '').trim();
91829306
if (!apiKey) {
@@ -9276,6 +9400,7 @@ function readClaudeSettingsRaw() {
92769400
}
92779401

92789402
function applyClaudeSettingsRaw(params = {}) {
9403+
assertToolConfigWriteAllowed('claude');
92799404
const content = typeof params.content === 'string' ? params.content : '';
92809405
if (!content.trim()) {
92819406
return { error: '内容不能为空' };
@@ -10773,10 +10898,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1077310898
const { action, params } = JSON.parse(body || '{}');
1077410899
let result;
1077510900

10776-
switch (action) {
10901+
const guardedToolConfigTarget = getApiToolConfigWriteTarget(action);
10902+
if (guardedToolConfigTarget && !isToolConfigWriteAllowed(guardedToolConfigTarget)) {
10903+
result = buildToolConfigWriteDeniedPayload(guardedToolConfigTarget);
10904+
} else {
10905+
switch (action) {
1077710906
case 'health-check':
1077810907
result = { ok: true };
1077910908
break;
10909+
case 'get-tool-config-permissions':
10910+
result = { permissions: readToolConfigPermissions() };
10911+
break;
10912+
case 'set-tool-config-permission':
10913+
result = setToolConfigPermission(params || {});
10914+
break;
1078010915
case 'status': {
1078110916
const statusConfigResult = readConfigOrVirtualDefault();
1078210917
const config = statusConfigResult.config;
@@ -10815,7 +10950,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1081510950
configReady: !statusConfigResult.isVirtual,
1081610951
configErrorType: statusConfigResult.errorType || '',
1081710952
configNotice: statusConfigResult.reason || '',
10818-
initNotice: consumeInitNotice()
10953+
initNotice: consumeInitNotice(),
10954+
toolConfigPermissions: readToolConfigPermissions()
1081910955
};
1082010956
break;
1082110957
}
@@ -11455,6 +11591,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
1145511591
break;
1145611592
default:
1145711593
result = { error: '未知操作' };
11594+
}
1145811595
}
1145911596

1146011597
const responseBody = JSON.stringify(result, null, 2);
@@ -16062,7 +16199,7 @@ async function main() {
1606216199
const args = process.argv.slice(2);
1606316200
const command = args[0];
1606416201
const isMcpCommand = command === 'mcp';
16065-
const bootstrap = ensureManagedConfigBootstrap();
16202+
const bootstrap = ensureManagedConfigBootstrap({ allowWrite: isToolConfigWriteAllowed('codex') });
1606616203
if (bootstrap && bootstrap.notice) {
1606716204
// MCP stdio transport requires stdout to be protocol-clean.
1606816205
if (!isMcpCommand) {

‎cli/config-bootstrap.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,12 @@ stream_idle_timeout_ms = 300000
273273
fs.writeFileSync(INIT_MARK_FILE, JSON.stringify(payload, null, 2), 'utf-8');
274274
}
275275

276-
function ensureManagedConfigBootstrap() {
276+
function ensureManagedConfigBootstrap(options = {}) {
277+
const allowWrite = !(options && options.allowWrite === false);
278+
if (!allowWrite) {
279+
initNotice = '';
280+
return { notice: '', readOnly: true };
281+
}
277282
ensureConfigDir();
278283

279284
const initializedAt = new Date().toISOString();

‎tests/unit/provider-share-command.test.mjs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ function createClaudeShareCommandBuilder(appSourceText, shareCommandPrefix = 'np
9595
}, payload);
9696
}
9797

98+
99+
test('tool config write guard maps provider write actions to per-tab permission targets', () => {
100+
const source = extractBlockBySignature(cliSource, 'function getApiToolConfigWriteTarget(action) {');
101+
const getApiToolConfigWriteTarget = instantiateFunction(source, 'getApiToolConfigWriteTarget');
102+
103+
assert.strictEqual(getApiToolConfigWriteTarget('add-provider'), 'codex');
104+
assert.strictEqual(getApiToolConfigWriteTarget('delete-provider'), 'codex');
105+
assert.strictEqual(getApiToolConfigWriteTarget('apply-claude-config'), 'claude');
106+
assert.strictEqual(getApiToolConfigWriteTarget('claude-local-bridge-sync-providers'), 'claude');
107+
assert.strictEqual(getApiToolConfigWriteTarget('status'), '');
108+
assert.strictEqual(getApiToolConfigWriteTarget('list-sessions'), '');
109+
});
110+
98111
test('buildProviderSharePayload includes model for shared provider', () => {
99112
const source = extractBlockBySignature(cliSource, 'function buildProviderSharePayload(params = {}) {');
100113
const buildProviderSharePayload = instantiateFunction(source, 'buildProviderSharePayload', {
@@ -623,7 +636,8 @@ test('status api case does not synthesize budget defaults after config load erro
623636
hasConfigLoadError,
624637
readPositiveIntegerConfigValue,
625638
readCurrentModels: () => ({}),
626-
consumeInitNotice: () => ''
639+
consumeInitNotice: () => '',
640+
readToolConfigPermissions: () => ({ codex: false, claude: false })
627641
}
628642
);
629643

‎tests/unit/web-run-host.test.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,15 @@ function createWebServerHarness({
699699
errors.push(args);
700700
}
701701
},
702+
getApiToolConfigWriteTarget() {
703+
return '';
704+
},
705+
isToolConfigWriteAllowed() {
706+
return false;
707+
},
708+
readToolConfigPermissions() {
709+
return { codex: false, claude: false };
710+
},
702711
startWinTray() {}
703712
});
704713

@@ -1049,6 +1058,15 @@ test('createWebServer health-check does not consume init notice before the first
10491058
consumed += 1;
10501059
return 'startup notice';
10511060
},
1061+
getApiToolConfigWriteTarget() {
1062+
return '';
1063+
},
1064+
isToolConfigWriteAllowed() {
1065+
return false;
1066+
},
1067+
readToolConfigPermissions() {
1068+
return { codex: false, claude: false };
1069+
},
10521070
startWinTray() {}
10531071
});
10541072

‎tests/unit/web-ui-behavior-parity.test.mjs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,11 @@ test('captured bundled app skeleton only exposes expected data key drift versus
353353
'showAddClaudeConfigKey',
354354
'showAddProviderKey',
355355
'showEditClaudeConfigKey',
356-
'showEditProviderKey'
356+
'showEditProviderKey',
357+
'toolConfigPermissionSaving',
358+
'toolConfigPermissions',
359+
'toolConfigPermissionSaving',
360+
'toolConfigPermissions'
357361
] : [
358362
'appVersion',
359363
'__mainTabSwitchState',
@@ -567,7 +571,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus
567571
'toggleAddClaudeConfigKey',
568572
'toggleAddProviderKey',
569573
'toggleEditClaudeConfigKey',
570-
'toggleEditProviderKey'
574+
'toggleEditProviderKey',
575+
'isToolConfigWriteAllowed',
576+
'toolConfigPermissionStatusLabel',
577+
'setToolConfigPermission'
571578
];
572579
allowedExtraCurrentMethodKeys.push(
573580
'hasActiveSessionFilters',

‎web-ui/app.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ document.addEventListener('DOMContentLoaded', () => {
366366
codexDownloadProgress: 0,
367367
codexDownloadTimer: null,
368368
settingsTab: 'general',
369+
toolConfigPermissions: { codex: false, claude: false },
370+
toolConfigPermissionSaving: { codex: false, claude: false },
369371
sessionTrashEnabled: true,
370372
sessionTrashItems: [],
371373
sessionTrashVisibleCount: SESSION_TRASH_PAGE_SIZE,

‎web-ui/modules/app.methods.index.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createOpenclawEditingMethods } from './app.methods.openclaw-editing.mjs
2020
import { createOpenclawPersistMethods } from './app.methods.openclaw-persist.mjs';
2121
import { createProvidersMethods } from './app.methods.providers.mjs';
2222
import { createRuntimeMethods } from './app.methods.runtime.mjs';
23+
import { createToolConfigPermissionMethods } from './app.methods.tool-config-permissions.mjs';
2324
import { createTaskOrchestrationMethods } from './app.methods.task-orchestration.mjs';
2425
import { createSessionActionMethods } from './app.methods.session-actions.mjs';
2526
import { createSessionBrowserMethods } from './app.methods.session-browser.mjs';
@@ -81,6 +82,7 @@ export function createAppMethods() {
8182
...createAgentsMethods({ api, apiWithMeta }),
8283
...createProvidersMethods({ api }),
8384
...createClaudeConfigMethods({ api }),
85+
...createToolConfigPermissionMethods({ api }),
8486
...createOpenclawCoreMethods(),
8587
...createOpenclawEditingMethods(),
8688
...createOpenclawPersistMethods({

‎web-ui/modules/app.methods.startup-claude.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export function createStartupClaudeMethods(options = {}) {
120120
: String(defaultModelAutoCompactTokenLimit);
121121
}
122122
}
123+
if (statusRes.toolConfigPermissions && typeof statusRes.toolConfigPermissions === 'object') {
124+
this.toolConfigPermissions = {
125+
codex: statusRes.toolConfigPermissions.codex === true,
126+
claude: statusRes.toolConfigPermissions.claude === true
127+
};
128+
}
123129
this.providersList = listRes.providers;
124130
if (typeof this.loadLocalBridgeExcluded === 'function') { this.loadLocalBridgeExcluded(); }
125131
if (typeof this.loadClaudeLocalBridgeStatus === 'function') { this.loadClaudeLocalBridgeStatus(); }

0 commit comments

Comments
 (0)