Skip to content

Commit 44dabd7

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix/autonomy-settings-patch-test-init
# Conflicts: # src/openhuman/inference/provider/factory_test.rs # src/openhuman/memory/chat.rs
2 parents f03a6d1 + 77c15cb commit 44dabd7

29 files changed

Lines changed: 760 additions & 19 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Tests for SearchPanel — the "Allowed websites" (unified web-access firewall)
3+
* section.
4+
*
5+
* Covers the tri-state access mode (Allow all / Custom / Block all):
6+
* - deriving the initial mode from the loaded settings,
7+
* - "Allow all" → persists `allow_all: true`,
8+
* - "Block all" → persists `allowed_domains: []` + `allow_all: false`,
9+
* - "Custom" → reveals the host editor and saving persists the list.
10+
*/
11+
import { fireEvent, screen, waitFor } from '@testing-library/react';
12+
import { beforeEach, describe, expect, test, vi } from 'vitest';
13+
14+
import { renderWithProviders } from '../../../test/test-utils';
15+
import SearchPanel from './SearchPanel';
16+
17+
// ---------------------------------------------------------------------------
18+
// Hoisted mocks
19+
// ---------------------------------------------------------------------------
20+
const hoisted = vi.hoisted(() => ({ getSearchSettings: vi.fn(), updateSearchSettings: vi.fn() }));
21+
22+
vi.mock('../../../utils/tauriCommands/config', () => ({
23+
openhumanGetSearchSettings: (...a: unknown[]) => hoisted.getSearchSettings(...a),
24+
openhumanUpdateSearchSettings: (...a: unknown[]) => hoisted.updateSearchSettings(...a),
25+
}));
26+
27+
// Identity translator so we can query by the stable i18n keys.
28+
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
29+
30+
vi.mock('../hooks/useSettingsNavigation', () => ({
31+
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
32+
}));
33+
34+
// Authed (non-local) session so the panel behaves normally.
35+
vi.mock('../../../utils/localSession', () => ({ isLocalSessionToken: () => false }));
36+
37+
function settings(overrides: Record<string, unknown> = {}) {
38+
return {
39+
engine: 'managed',
40+
effective_engine: 'managed',
41+
max_results: 5,
42+
timeout_secs: 15,
43+
parallel_configured: false,
44+
brave_configured: false,
45+
allowed_domains: ['reuters.com'],
46+
allow_all: false,
47+
...overrides,
48+
};
49+
}
50+
51+
const PLACEHOLDER = 'settings.search.allowedSitesPlaceholder';
52+
const ALLOW_ALL = 'settings.search.accessAllowAll';
53+
const CUSTOM = 'settings.search.accessCustom';
54+
const BLOCK_ALL = 'settings.search.accessBlockAll';
55+
56+
const radio = (name: string) => screen.getByRole('radio', { name });
57+
58+
describe('SearchPanel — unified web-access modes', () => {
59+
beforeEach(() => {
60+
hoisted.getSearchSettings.mockReset();
61+
hoisted.updateSearchSettings.mockReset();
62+
hoisted.getSearchSettings.mockResolvedValue({ result: settings() });
63+
hoisted.updateSearchSettings.mockResolvedValue({ result: {} });
64+
});
65+
66+
test('explicit host list → starts in Custom mode with the editor populated', async () => {
67+
renderWithProviders(<SearchPanel embedded />);
68+
// The textarea mounts empty, then a one-time sync effect fills it from
69+
// settings on the next tick — wait for the value rather than asserting now.
70+
await waitFor(() => {
71+
const ta = screen.getByPlaceholderText(PLACEHOLDER) as HTMLTextAreaElement;
72+
expect(ta.value).toBe('reuters.com');
73+
});
74+
expect(radio(CUSTOM)).toHaveAttribute('aria-checked', 'true');
75+
expect(radio(ALLOW_ALL)).toHaveAttribute('aria-checked', 'false');
76+
});
77+
78+
test('selecting "Allow all" persists allow_all: true and hides the editor', async () => {
79+
renderWithProviders(<SearchPanel embedded />);
80+
await screen.findByPlaceholderText(PLACEHOLDER);
81+
82+
fireEvent.click(radio(ALLOW_ALL));
83+
84+
await waitFor(() =>
85+
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({ allow_all: true })
86+
);
87+
expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull();
88+
});
89+
90+
test('selecting "Block all" persists an empty allowlist and hides the editor', async () => {
91+
renderWithProviders(<SearchPanel embedded />);
92+
await screen.findByPlaceholderText(PLACEHOLDER);
93+
94+
fireEvent.click(radio(BLOCK_ALL));
95+
96+
await waitFor(() =>
97+
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({
98+
allowed_domains: [],
99+
allow_all: false,
100+
})
101+
);
102+
expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull();
103+
});
104+
105+
test('Custom: saving an edited host list persists allowed_domains + allow_all: false', async () => {
106+
renderWithProviders(<SearchPanel embedded />);
107+
const textarea = await screen.findByPlaceholderText(PLACEHOLDER);
108+
109+
fireEvent.change(textarea, { target: { value: 'github.com\n apnews.com \n\n' } });
110+
fireEvent.click(screen.getByText('settings.search.allowedSitesSave'));
111+
112+
await waitFor(() =>
113+
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({
114+
allowed_domains: ['github.com', 'apnews.com'],
115+
allow_all: false,
116+
})
117+
);
118+
});
119+
120+
test('Custom: pasted URLs are normalized to bare hosts before persisting', async () => {
121+
renderWithProviders(<SearchPanel embedded />);
122+
const textarea = await screen.findByPlaceholderText(PLACEHOLDER);
123+
124+
// Users paste full URLs; url_guard matches on host, so a scheme/path entry
125+
// would never match. The editor strips both down to the bare host.
126+
fireEvent.change(textarea, {
127+
target: { value: 'https://reuters.com/markets\nhttp://apnews.com/\ngithub.com' },
128+
});
129+
fireEvent.click(screen.getByText('settings.search.allowedSitesSave'));
130+
131+
await waitFor(() =>
132+
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({
133+
allowed_domains: ['reuters.com', 'apnews.com', 'github.com'],
134+
allow_all: false,
135+
})
136+
);
137+
});
138+
139+
test('allow_all settings → starts in Allow-all mode with no editor', async () => {
140+
hoisted.getSearchSettings.mockResolvedValue({
141+
result: settings({ allowed_domains: ['*'], allow_all: true }),
142+
});
143+
renderWithProviders(<SearchPanel embedded />);
144+
145+
await waitFor(() => expect(radio(ALLOW_ALL)).toHaveAttribute('aria-checked', 'true'));
146+
expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull();
147+
});
148+
149+
test('empty allowlist → starts in Block-all mode with no editor', async () => {
150+
hoisted.getSearchSettings.mockResolvedValue({
151+
result: settings({ allowed_domains: [], allow_all: false }),
152+
});
153+
renderWithProviders(<SearchPanel embedded />);
154+
155+
await waitFor(() => expect(radio(BLOCK_ALL)).toHaveAttribute('aria-checked', 'true'));
156+
expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull();
157+
});
158+
159+
test('switching Block → Custom keeps the previously typed hosts', async () => {
160+
renderWithProviders(<SearchPanel embedded />);
161+
const textarea = (await screen.findByPlaceholderText(PLACEHOLDER)) as HTMLTextAreaElement;
162+
fireEvent.change(textarea, { target: { value: 'example.com' } });
163+
164+
// Block all (persists empty list) then back to Custom — the editor text is
165+
// local state and must survive the round trip so the user doesn't lose it.
166+
fireEvent.click(radio(BLOCK_ALL));
167+
await waitFor(() => expect(screen.queryByPlaceholderText(PLACEHOLDER)).toBeNull());
168+
fireEvent.click(radio(CUSTOM));
169+
170+
const reopened = (await screen.findByPlaceholderText(PLACEHOLDER)) as HTMLTextAreaElement;
171+
expect(reopened.value).toBe('example.com');
172+
});
173+
});

app/src/components/settings/panels/SearchPanel.tsx

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22

33
import { useT } from '../../../lib/i18n/I18nContext';
44
import { useCoreState } from '../../../providers/CoreStateProvider';
@@ -8,6 +8,7 @@ import {
88
openhumanUpdateSearchSettings,
99
type SearchEngineId,
1010
type SearchSettings,
11+
type SearchSettingsUpdate,
1112
} from '../../../utils/tauriCommands/config';
1213
import SettingsHeader from '../components/SettingsHeader';
1314
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -19,13 +20,38 @@ type Status =
1920
| { kind: 'saved' }
2021
| { kind: 'error'; message: string };
2122

23+
/**
24+
* Tri-state web-access mode for the unified fetch + browser allowlist.
25+
* - `all` → `allow_all: true` (the `"*"` wildcard)
26+
* - `custom` → `allow_all: false` + an explicit host list (textarea)
27+
* - `block` → `allow_all: false` + an empty host list (no web access)
28+
*
29+
* `block` and an empty `custom` are indistinguishable once persisted (both are
30+
* `allow_all: false` + `[]`); the distinction only matters locally while
31+
* editing.
32+
*/
33+
type AccessMode = 'all' | 'custom' | 'block';
34+
2235
interface EngineOption {
2336
id: SearchEngineId;
2437
label: string;
2538
description: string;
2639
requiresKey: boolean;
2740
}
2841

42+
/**
43+
* Normalize a user-entered allowed-site entry down to a bare host so it
44+
* matches `url_guard`'s host-based comparison. Strips a leading scheme and any
45+
* path/query/fragment — e.g. `https://reuters.com/markets` → `reuters.com` —
46+
* and trims surrounding whitespace. The `*` allow-all wildcard is preserved.
47+
*/
48+
const normalizeAllowedHost = (raw: string): string =>
49+
raw
50+
.trim()
51+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
52+
.replace(/\/.*$/, '')
53+
.trim();
54+
2955
const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
3056
const { t } = useT();
3157
const { navigateBack, breadcrumbs } = useSettingsNavigation();
@@ -38,6 +64,15 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
3864
const [braveKey, setBraveKey] = useState<string>('');
3965
const [showParallel, setShowParallel] = useState(false);
4066
const [showBrave, setShowBrave] = useState(false);
67+
// Editor text for the allowed-websites host list (one host per line). The
68+
// "*" wildcard is represented by the access mode, not shown here.
69+
const [allowedText, setAllowedText] = useState<string>('');
70+
// Tri-state web-access mode for the unified fetch + browser allowlist.
71+
const [mode, setMode] = useState<AccessMode>('all');
72+
// Sync editor + mode from settings exactly once, so a later settings refresh
73+
// (e.g. after saving an engine change) can't clobber the user's in-progress
74+
// host edits or chosen mode.
75+
const initializedRef = useRef(false);
4176

4277
const ENGINES: EngineOption[] = [
4378
{
@@ -80,6 +115,15 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
80115
};
81116
}, []);
82117

118+
// Reflect the loaded allowlist into the editor + mode, exactly once.
119+
useEffect(() => {
120+
if (!settings || initializedRef.current) return;
121+
initializedRef.current = true;
122+
const explicit = settings.allowed_domains.filter(d => d !== '*');
123+
setAllowedText(explicit.join('\n'));
124+
setMode(settings.allow_all ? 'all' : explicit.length > 0 ? 'custom' : 'block');
125+
}, [settings]);
126+
83127
const selectedEngine = (settings?.engine as SearchEngineId | undefined) ?? 'managed';
84128

85129
const persistEngine = async (next: SearchEngineId) => {
@@ -115,6 +159,38 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
115159
}
116160
};
117161

162+
const persistSearchUpdate = async (update: SearchSettingsUpdate) => {
163+
if (!settings || status.kind === 'saving') return;
164+
setStatus({ kind: 'saving' });
165+
try {
166+
await openhumanUpdateSearchSettings(update);
167+
const refreshed = await openhumanGetSearchSettings();
168+
setSettings(refreshed.result);
169+
setStatus({ kind: 'saved' });
170+
} catch (err) {
171+
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
172+
}
173+
};
174+
175+
// Switch web-access mode. "Allow all" / "Block all" persist immediately;
176+
// "Custom" only reveals the host editor (its Save button persists the list),
177+
// and we keep whatever the user has already typed.
178+
const selectMode = (next: AccessMode) => {
179+
if (status.kind === 'saving') return;
180+
setMode(next);
181+
if (next === 'all') {
182+
void persistSearchUpdate({ allow_all: true });
183+
} else if (next === 'block') {
184+
void persistSearchUpdate({ allowed_domains: [], allow_all: false });
185+
}
186+
};
187+
188+
const persistAllowedDomains = () => {
189+
const domains = allowedText.split('\n').map(normalizeAllowedHost).filter(Boolean);
190+
// Editing the explicit host list implies "not allow-all".
191+
void persistSearchUpdate({ allowed_domains: domains, allow_all: false });
192+
};
193+
118194
const isConfigured = (engine: SearchEngineId): boolean => {
119195
if (!settings) return false;
120196
if (engine === 'managed') return true;
@@ -260,6 +336,75 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
260336
/>
261337
</div>
262338

339+
{/* Allowed websites — unified host allowlist shared by web_fetch /
340+
curl and (when enabled) the browser tool. Web search is not
341+
gated by this list. */}
342+
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 space-y-2">
343+
{/* Section heading, not a form label — use a <p> so screen
344+
readers don't announce an orphan <label> with no htmlFor. */}
345+
<p className="text-xs font-semibold text-stone-700 dark:text-neutral-200">
346+
{t('settings.search.allowedSitesLabel')}
347+
</p>
348+
<div
349+
role="radiogroup"
350+
aria-label={t('settings.search.accessModeAria')}
351+
className="flex rounded-lg border border-stone-200 dark:border-neutral-800 overflow-hidden">
352+
{(
353+
[
354+
['all', 'settings.search.accessAllowAll'],
355+
['custom', 'settings.search.accessCustom'],
356+
['block', 'settings.search.accessBlockAll'],
357+
] as const
358+
).map(([value, labelKey], idx) => {
359+
const selected = mode === value;
360+
return (
361+
<button
362+
key={value}
363+
type="button"
364+
role="radio"
365+
aria-checked={selected}
366+
onClick={() => selectMode(value)}
367+
disabled={status.kind === 'saving'}
368+
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30 ${
369+
idx !== 0 ? 'border-l border-stone-200 dark:border-neutral-800' : ''
370+
} ${
371+
selected
372+
? 'bg-primary-500 text-white'
373+
: 'bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
374+
}`}>
375+
{t(labelKey)}
376+
</button>
377+
);
378+
})}
379+
</div>
380+
<p className="text-[11px] text-stone-500 dark:text-neutral-400 leading-relaxed">
381+
{mode === 'all'
382+
? t('settings.search.allowedSitesAllOn')
383+
: mode === 'block'
384+
? t('settings.search.accessBlockAllHint')
385+
: t('settings.search.allowedSitesHint')}
386+
</p>
387+
{mode === 'custom' && (
388+
<>
389+
<textarea
390+
value={allowedText}
391+
onChange={e => setAllowedText(e.target.value)}
392+
rows={4}
393+
spellCheck={false}
394+
placeholder={t('settings.search.allowedSitesPlaceholder')}
395+
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-2 py-1.5 text-xs font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus-visible:border-primary-400"
396+
/>
397+
<button
398+
type="button"
399+
onClick={() => persistAllowedDomains()}
400+
disabled={status.kind === 'saving'}
401+
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50">
402+
{t('settings.search.allowedSitesSave')}
403+
</button>
404+
</>
405+
)}
406+
</div>
407+
263408
<div
264409
role="status"
265410
aria-live="polite"

app/src/lib/i18n/chunks/ar-1.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,19 @@ const ar1: TranslationMap = {
13191319
'subconscious.priority.normal': 'عادي',
13201320
'subconscious.durationSeconds': '{seconds}s',
13211321
'subconscious.durationMilliseconds': '{milliseconds}ms',
1322+
'settings.search.allowedSitesLabel': 'Allowed websites',
1323+
'settings.search.allowedSitesHint':
1324+
'Websites the assistant may open and read while researching (one host per line, e.g. reuters.com). A host also covers its subdomains. Leave empty to block all web access.',
1325+
'settings.search.allowedSitesAllOn':
1326+
'The assistant can open any public website. Local and private addresses stay blocked.',
1327+
'settings.search.allowedSitesPlaceholder': 'reuters.com\napnews.com\ngithub.com',
1328+
'settings.search.allowedSitesSave': 'Save websites',
1329+
'settings.search.accessModeAria': 'Web access mode',
1330+
'settings.search.accessAllowAll': 'Allow all',
1331+
'settings.search.accessCustom': 'Custom',
1332+
'settings.search.accessBlockAll': 'Block all',
1333+
'settings.search.accessBlockAllHint':
1334+
'All web access is blocked — the assistant cannot open or read any website.',
13221335
};
13231336

13241337
export default ar1;

0 commit comments

Comments
 (0)