feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+106
View File
@@ -7,15 +7,20 @@ import {
fetchMcpPresets,
fetchSidebarState,
fetchWebuiThread,
fetchWorkspaces,
importMcpConfig,
listSessions,
listSlashCommands,
loginProviderOAuth,
logoutProviderOAuth,
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
updateSidebarState,
updateImageGenerationSettings,
updateModelConfiguration,
updateMcpServerTools,
updateNetworkSafetySettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -89,6 +94,44 @@ describe("webui API helpers", () => {
);
});
it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", {
name: "codex",
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reports HTML API fallbacks as gateway mismatch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
text: async () => "<!doctype html><html></html>",
}),
);
await expect(
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
});
});
it("serializes provider settings updates without returning secrets", async () => {
await updateProviderSettings("tok", {
provider: "openrouter",
@@ -104,6 +147,24 @@ describe("webui API helpers", () => {
);
});
it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
@@ -121,6 +182,20 @@ describe("webui API helpers", () => {
);
});
it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", {
webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full",
});
expect(fetch).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", {
enabled: true,
@@ -257,6 +332,7 @@ describe("webui API helpers", () => {
pinned_keys: ["websocket:chat-1"],
archived_keys: ["websocket:old"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
tags_by_key: {},
collapsed_groups: {},
view: {
@@ -292,9 +368,39 @@ describe("webui API helpers", () => {
expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
pinned_keys: ["websocket:chat-1"],
title_overrides: { "websocket:chat-1": "Release" },
project_name_overrides: { "/Users/me/nanobot": "Core" },
});
});
it("fetches workspace project state", async () => {
const payload = {
schema_version: 1,
default_access_mode: "default" as const,
default_scope: {
project_path: "/tmp/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
},
controls: {
can_change_project: true,
can_use_full_access: true,
},
};
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => payload,
} as Response);
await expect(fetchWorkspaces("tok")).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith(
"/api/workspaces",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,