Save the API write allowlist one change at a time

Two quick toggles sent overlapping saves that could land out of order, and a
failed first save put back a list without the second choice. The choices
now lock while a save is in flight.
This commit is contained in:
arc53-machine committed 2026-09-29 19:15:34 +01:00
1 parent dab5aca96e
commit 427b83c785
2 files changed
+31

No files matched your search

@@ -297,6 +297,23 @@ describe('ApiWriteAllowlist', () => {
);
});
it('locks the choices while a save is in flight, so saves never overlap', async () => {
let finish: (value: { ok: boolean }) => void = () => undefined;
updateAgent.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
await render(agent());
await expand();
await act(async () => choice('tg', 'all').click());
expect(choice('tg', 'off').hasAttribute('disabled')).toBe(true);
await act(async () => choice('tg', 'off').click());
expect(updateAgent).toHaveBeenCalledTimes(1);
await act(async () => finish({ ok: true }));
expect(choice('tg', 'off').hasAttribute('disabled')).toBe(false);
});
it('turns a tool off again, keeping entries of tools no longer listed', async () => {
updateAgent.mockResolvedValue({ ok: true });
await render(
+14
View File
@@ -49,10 +49,13 @@ function ToolAllowlist({
tool,
allowed,
onChange,
disabled = false,
}: {
tool: ToolWrites;
allowed: Set<string>;
onChange: (entries: string[], allow: boolean) => void;
/** While a save is in flight, so saves never overlap. */
disabled?: boolean;
}) {
const { t } = useTranslation();
const [unfolded, setUnfolded] = useState(false);
@@ -86,6 +89,7 @@ function ToolAllowlist({
type="single"
size="xs"
value={choice}
disabled={disabled}
aria-label={t(`${K}.toolLabel`, { ...plain, tool: tool.name })}
onValueChange={(value) =>
value &&
@@ -136,6 +140,7 @@ function ToolAllowlist({
<Switch
id={id}
checked={allowed.has(item.entry)}
disabled={disabled}
onCheckedChange={(checked) => onChange([item.entry], checked)}
/>
</SettingRow>
@@ -181,6 +186,10 @@ export default function ApiWriteAllowlist({
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [open, setOpen] = useState(defaultOpen);
// One save at a time: overlapping saves can land out of order, and a
// failed one would put back a list that drops the later choice.
const [saving, setSaving] = useState(false);
const [descriptions, setDescriptions] = useState<Record<string, string>>({});
const [allowed, setAllowed] = useState<string[]>(
agent.config?.api_write_allowlist ?? [],
@@ -260,6 +269,8 @@ export default function ApiWriteAllowlist({
/** Allow or refuse these entries, and save the list. */
const change = async (entries: string[], allow: boolean) => {
if (saving) return;
setSaving(true);
const previous = allowed;
const next = allow
? [...allowed, ...entries.filter((entry) => !allowed.includes(entry))]
@@ -287,6 +298,8 @@ export default function ApiWriteAllowlist({
message: t(`${K}.saveFailed`),
}),
);
} finally {
setSaving(false);
}
};
@@ -330,6 +343,7 @@ export default function ApiWriteAllowlist({
tool={tool}
allowed={allowedSet}
onChange={change}
disabled={saving}
/>
))}
</Card>