Skip to content

Commit 0bcac44

Browse files
committed
fix(serve): address wenshao review — scope restriction, restart message, and hardening
- Restrict POST /workspace/settings to workspace scope only (remove user scope) - Fix requiresRestart message being cleared by useEffect (track restartPending state) - Remove explicit reload() — let SSE event-driven reload handle it (fixes double reload) - Add busyKey guard to prevent double-submit during save - Add string max length validation (1024 chars) - Sanitize error messages — don't leak filesystem paths in HTTP responses - Remove corruptedPath from GET response — only return recovered boolean - Extract shared getAllowedKeys() to deduplicate filter logic - Replace scopeToEnum with explicit SCOPE_MAP - Separate persist and broadcast try/catch blocks - Add settings_changed case to asKnownDaemonEvent and reducer - Add workspace_settings to EXPECTED_REGISTERED_FEATURES test array
1 parent ea8c196 commit 0bcac44

5 files changed

Lines changed: 54 additions & 35 deletions

File tree

packages/cli/src/serve/routes/workspaceSettings.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ const TUI_ONLY_SETTINGS = new Set([
3333
'ui.accessibility.enableLoadingPhrases',
3434
]);
3535

36-
const VALID_WRITE_SCOPES = new Set(['user', 'workspace']);
36+
const VALID_WRITE_SCOPES = new Set(['workspace']);
37+
38+
const MAX_STRING_VALUE_LENGTH = 1024;
3739

3840

3941
interface SettingDescriptor {
@@ -56,18 +58,22 @@ interface SettingsResponse {
5658
v: 1;
5759
warnings?: Array<{
5860
type: 'corrupted';
59-
scope: string;
60-
path: string;
6161
recovered: boolean;
6262
}>;
6363
settings: SettingDescriptor[];
6464
}
6565

66+
function getAllowedKeys(): Set<string> {
67+
return new Set(
68+
getDialogSettingKeys().filter((k) => !TUI_ONLY_SETTINGS.has(k)),
69+
);
70+
}
71+
6672
function buildSettingsResponse(
6773
boundWorkspace: string,
6874
): SettingsResponse {
6975
const loaded = loadSettings(boundWorkspace);
70-
const keys = getDialogSettingKeys().filter((k) => !TUI_ONLY_SETTINGS.has(k));
76+
const keys = Array.from(getAllowedKeys());
7177

7278
const settings: SettingDescriptor[] = [];
7379
for (const key of keys) {
@@ -110,8 +116,6 @@ function buildSettingsResponse(
110116
if (loaded.corruptedPath) {
111117
warnings.push({
112118
type: 'corrupted',
113-
scope: 'unknown',
114-
path: loaded.corruptedPath,
115119
recovered: loaded.wasRecovered,
116120
});
117121
}
@@ -137,6 +141,8 @@ function validateSettingValue(
137141
break;
138142
case 'string':
139143
if (typeof value !== 'string') return 'Value must be a string';
144+
if (value.length > MAX_STRING_VALUE_LENGTH)
145+
return `Value exceeds ${MAX_STRING_VALUE_LENGTH}-character limit`;
140146
break;
141147
case 'enum':
142148
if (
@@ -154,9 +160,10 @@ function validateSettingValue(
154160
return undefined;
155161
}
156162

157-
function scopeToEnum(scope: string): SettingScope {
158-
return scope === 'user' ? SettingScope.User : SettingScope.Workspace;
159-
}
163+
const SCOPE_MAP: Record<string, SettingScope> = {
164+
user: SettingScope.User,
165+
workspace: SettingScope.Workspace,
166+
};
160167

161168
export interface WorkspaceSettingsRouteDeps {
162169
boundWorkspace: string;
@@ -193,9 +200,7 @@ export function registerWorkspaceSettingsRoutes(
193200
parseAndValidateClientId,
194201
} = deps;
195202

196-
const allowedKeys = new Set(
197-
getDialogSettingKeys().filter((k) => !TUI_ONLY_SETTINGS.has(k)),
198-
);
203+
const allowedKeys = getAllowedKeys();
199204

200205
app.get('/workspace/settings', (_req: Request, res: Response) => {
201206
try {
@@ -208,7 +213,7 @@ export function registerWorkspaceSettingsRoutes(
208213
}`,
209214
);
210215
res.status(500).json({
211-
error: err instanceof Error ? err.message : String(err),
216+
error: 'Failed to load settings',
212217
code: 'internal_error',
213218
});
214219
}
@@ -277,27 +282,36 @@ export function registerWorkspaceSettingsRoutes(
277282
if (clientId === null) return;
278283

279284
try {
280-
await persistSetting(boundWorkspace, scopeToEnum(scope), key, value);
281-
282-
broadcastSettingsChanged(key, value, scope, clientId);
283-
284-
res.status(200).json({
285-
key,
286-
scope,
287-
value,
288-
requiresRestart: def.requiresRestart,
289-
});
285+
await persistSetting(boundWorkspace, SCOPE_MAP[scope]!, key, value);
290286
} catch (err) {
291287
writeStderrLine(
292-
`qwen serve: POST /workspace/settings error: ${
288+
`qwen serve: POST /workspace/settings persist error: ${
293289
err instanceof Error ? err.message : String(err)
294290
}`,
295291
);
296292
res.status(500).json({
297-
error: err instanceof Error ? err.message : String(err),
293+
error: 'Failed to persist setting',
298294
code: 'persist_error',
299295
});
296+
return;
297+
}
298+
299+
try {
300+
broadcastSettingsChanged(key, value, scope, clientId);
301+
} catch (err) {
302+
writeStderrLine(
303+
`qwen serve: POST /workspace/settings broadcast error: ${
304+
err instanceof Error ? err.message : String(err)
305+
}`,
306+
);
300307
}
308+
309+
res.status(200).json({
310+
key,
311+
scope,
312+
value,
313+
requiresRestart: def.requiresRestart,
314+
});
301315
},
302316
);
303317
}

packages/cli/src/serve/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ const EXPECTED_REGISTERED_FEATURES = [
207207
'permission_mediation',
208208
'prompt_absolute_deadline',
209209
'writer_idle_timeout',
210+
'workspace_settings',
210211
'non_blocking_prompt',
211212
] as const;
212213

packages/sdk-typescript/src/daemon/events.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,10 @@ export function asKnownDaemonEvent(
13011301
return isToolToggledData(event.data)
13021302
? (event as DaemonToolToggledEvent)
13031303
: undefined;
1304+
case 'settings_changed':
1305+
return event.data != null && typeof event.data === 'object'
1306+
? (event as DaemonEventEnvelope<'settings_changed', Record<string, unknown>>)
1307+
: undefined;
13041308
case 'workspace_initialized':
13051309
return isWorkspaceInitializedData(event.data)
13061310
? (event as DaemonWorkspaceInitializedEvent)
@@ -1638,14 +1642,13 @@ export function reduceDaemonSessionEvent(
16381642
lastApprovalModeChange: mergeOriginator(event.data, event),
16391643
};
16401644
case 'tool_toggled':
1641-
// Workspace-scoped — same `tool_toggled` envelope is fan-out to
1642-
// every session, so adapters can render "this tool was disabled
1643-
// by another client" without polling.
16441645
return {
16451646
...base,
16461647
toolToggleCount: base.toolToggleCount + 1,
16471648
lastToolToggle: mergeOriginator(event.data, event),
16481649
};
1650+
case 'settings_changed':
1651+
return base;
16491652
case 'workspace_initialized':
16501653
// Workspace-scoped fan-out. Non-terminal — just records that a
16511654
// QWEN.md scaffold was performed.

packages/sdk-typescript/src/daemon/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,8 +1020,6 @@ export interface DaemonWorkspaceSettingsStatus {
10201020
v: 1;
10211021
warnings?: Array<{
10221022
type: 'corrupted';
1023-
scope: string;
1024-
path: string;
10251023
recovered: boolean;
10261024
}>;
10271025
settings: DaemonSettingDescriptor[];

packages/web-shell/client/components/dialogs/SettingsDialog.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ export function SettingsDialog({
171171
)
172172
.join('; '),
173173
);
174-
else if (settings.length > 0) setMessage(null);
175-
}, [error, settings, status, t]);
174+
else if (settings.length > 0 && !restartPending) setMessage(null);
175+
}, [error, settings, status, t, restartPending]);
176176

177177
useEffect(() => {
178178
if (selectedIdx >= rows.length && rows.length > 0) {
@@ -193,14 +193,16 @@ export function SettingsDialog({
193193
}
194194
}, [editMode]);
195195

196+
const [restartPending, setRestartPending] = useState(false);
197+
196198
const handleSetValue = useCallback(
197199
(key: string, value: unknown) => {
198200
setMessage(null);
199201
setBusyKey(key);
200202
setValue(scope, key, value)
201203
.then((result) => {
202-
reload();
203204
if (result?.requiresRestart) {
205+
setRestartPending(true);
204206
setMessage(t('settings.requiresRestart'));
205207
}
206208
})
@@ -209,7 +211,7 @@ export function SettingsDialog({
209211
})
210212
.finally(() => setBusyKey(null));
211213
},
212-
[scope, setValue, reload, t],
214+
[scope, setValue, t],
213215
);
214216

215217
const handleAction = useCallback(
@@ -297,7 +299,7 @@ export function SettingsDialog({
297299
reload();
298300
return;
299301
}
300-
if (e.key === 'Enter' || e.key === ' ') {
302+
if ((e.key === 'Enter' || e.key === ' ') && !busyKey) {
301303
e.preventDefault();
302304
const row = rows[selectedIdx];
303305
if (row?.type === 'setting' && row.setting) {
@@ -306,6 +308,7 @@ export function SettingsDialog({
306308
}
307309
},
308310
[
311+
busyKey,
309312
editMode,
310313
handleAction,
311314
handleEditSubmit,

0 commit comments

Comments
 (0)