mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 12:58:49 +08:00
Simplify backend registry selection (#1046)
* Add partial stack modes to agent-canvas Co-authored-by: openhands <openhands@all-hands.dev> * Simplify backend registry selection Co-authored-by: openhands <openhands@all-hands.dev> * Fix backend selection CI regressions * Seed local proxy backend in cloud tests * Stabilize onboarding snapshot navigation * Stabilize ingress tests on Windows * Remove local backend fallback for cloud calls * Fix frontend-only backend proxy target * Test backend-only launch without build * Add pending workflow and status updates * Use active LLM profile for setup banner * Show backend connection errors before saving * Validate backend keys in health checks * Update backend selector health test mock * Fix frontend-only workspace path * Preserve OpenHands proxy base URL * Address backend review comments * Preserve profile config when switching models * Fail fast on profile export errors * Throw AgentServerUnavailableError when backend registry is empty When no backend is configured (empty registry / NO_BACKEND sentinel), loadAgentServerInfo() was returning null without throwing, causing OptionService.getConfig() to succeed silently. root.tsx then rendered the home page instead of the MissingAgentServerScreen with the manage backends modal. Now loadAgentServerInfo() checks for the NO_BACKEND sentinel when getEffectiveLocalBackend() returns null and throws AgentServerUnavailableError, which root.tsx already handles by showing the manage backends modal. The cloud-backend path (also null from getEffectiveLocalBackend) is preserved — it still returns null. Co-authored-by: openhands <openhands@all-hands.dev> * chore: Remove PR-only artifacts --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
allhands-bot
parent
3122fa9a56
commit
b9d78d3116
@@ -6,7 +6,10 @@ import {
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { loadAgentServerInfo } from "#/api/agent-server-compatibility";
|
||||
import {
|
||||
AgentServerUnavailableError,
|
||||
loadAgentServerInfo,
|
||||
} from "#/api/agent-server-compatibility";
|
||||
|
||||
const { getServerInfoMock } = vi.hoisted(() => ({
|
||||
getServerInfoMock: vi.fn(),
|
||||
@@ -28,6 +31,14 @@ const cloudBackend: Backend = {
|
||||
kind: "cloud",
|
||||
};
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local",
|
||||
name: "Local",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "local-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
@@ -42,22 +53,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("loadAgentServerInfo", () => {
|
||||
it("targets the bundled local backend even when the active backend is cloud", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
it("does not borrow a registered local backend when the active backend is cloud", async () => {
|
||||
setRegisteredBackends([localBackend, cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
await loadAgentServerInfo();
|
||||
const result = await loadAgentServerInfo();
|
||||
|
||||
expect(ServerClient).toHaveBeenCalledOnce();
|
||||
const callArgs = vi.mocked(ServerClient).mock.calls[0] as unknown as [
|
||||
{ host?: string; apiKey?: string | null },
|
||||
];
|
||||
const overrides = callArgs[0];
|
||||
expect(result).toBeNull();
|
||||
expect(ServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Must NOT use the cloud host — that endpoint doesn't exist on cloud
|
||||
// and would fail with a CORS preflight error.
|
||||
expect(overrides.host).toBeDefined();
|
||||
expect(overrides.host).not.toBe(cloudBackend.host);
|
||||
expect(overrides.host).not.toContain("all-hands.dev");
|
||||
it("throws AgentServerUnavailableError when the registry is empty", async () => {
|
||||
// Empty registry — no backends at all (frontend-only with no config).
|
||||
setRegisteredBackends([]);
|
||||
|
||||
await expect(loadAgentServerInfo()).rejects.toThrow(
|
||||
AgentServerUnavailableError,
|
||||
);
|
||||
expect(ServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
DEFAULT_WORKING_DIR,
|
||||
buildConversationWorkingDir,
|
||||
getAgentServerBaseUrl,
|
||||
@@ -9,9 +8,7 @@ import {
|
||||
getAgentServerWorkingDir,
|
||||
isAuthRequired,
|
||||
isAuthRequiredAndMissing,
|
||||
saveAgentServerConfig,
|
||||
shouldLoadPublicSkills,
|
||||
syncBakedSessionApiKey,
|
||||
} from "#/api/agent-server-config";
|
||||
|
||||
const ORIGINAL_LOCATION = window.location;
|
||||
@@ -33,37 +30,27 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("agent server config", () => {
|
||||
it("uses the browser origin when a remote browser is pointed at localhost backend config", () => {
|
||||
it("uses VITE_BACKEND_BASE_URL when it is provided", () => {
|
||||
mockWindowLocation("https://work-1.example.dev/settings");
|
||||
window.localStorage.setItem(
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify({ baseUrl: "http://127.0.0.1:8000" }),
|
||||
);
|
||||
|
||||
expect(getAgentServerBaseUrl()).toBe("https://work-1.example.dev");
|
||||
});
|
||||
|
||||
it("uses the browser origin when browser is at localhost but config uses 127.0.0.1 (Docker CORS fix)", () => {
|
||||
mockWindowLocation("http://localhost:8000/");
|
||||
window.localStorage.setItem(
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify({ baseUrl: "http://127.0.0.1:8000" }),
|
||||
);
|
||||
|
||||
expect(getAgentServerBaseUrl()).toBe("http://localhost:8000");
|
||||
});
|
||||
|
||||
it("preserves a non-local backend URL from stored config", () => {
|
||||
mockWindowLocation("https://work-1.example.dev/settings");
|
||||
window.localStorage.setItem(
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify({ baseUrl: "https://agent.example.com" }),
|
||||
);
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "https://agent.example.com/");
|
||||
|
||||
expect(getAgentServerBaseUrl()).toBe("https://agent.example.com");
|
||||
});
|
||||
|
||||
it("prefills the settings form from environment defaults when local settings are empty", () => {
|
||||
it("uses the browser origin when no backend URL is configured", () => {
|
||||
mockWindowLocation("https://work-1.example.dev/settings");
|
||||
|
||||
expect(getAgentServerBaseUrl()).toBe("https://work-1.example.dev");
|
||||
});
|
||||
|
||||
it("does not rewrite localhost backend URLs to the browser origin", () => {
|
||||
mockWindowLocation("https://work-1.example.dev/settings");
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
||||
|
||||
expect(getAgentServerBaseUrl()).toBe("http://127.0.0.1:8000");
|
||||
});
|
||||
|
||||
it("prefills the settings form from environment defaults", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com/");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key");
|
||||
|
||||
@@ -78,7 +65,7 @@ describe("agent server config", () => {
|
||||
expect(getAgentServerWorkingDir()).toBe(DEFAULT_WORKING_DIR);
|
||||
});
|
||||
|
||||
it("nests each conversation's working dir under the configured base using the hex id (matching the server's persistence dir name)", () => {
|
||||
it("nests each conversation's working dir under the configured base using the hex id", () => {
|
||||
vi.stubEnv("VITE_WORKING_DIR", "/srv/workspaces/");
|
||||
|
||||
expect(
|
||||
@@ -86,23 +73,6 @@ describe("agent server config", () => {
|
||||
).toBe("/srv/workspaces/4a8dca373bf048dea0af949d711c3d48");
|
||||
});
|
||||
|
||||
it("lets saved interface settings override environment defaults", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key");
|
||||
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "https://saved-agent.example.com/",
|
||||
sessionApiKey: "saved-session-key ",
|
||||
});
|
||||
|
||||
expect(getAgentServerFormDefaults()).toEqual({
|
||||
baseUrl: "https://saved-agent.example.com",
|
||||
sessionApiKey: "saved-session-key",
|
||||
});
|
||||
expect(getAgentServerBaseUrl()).toBe("https://saved-agent.example.com");
|
||||
expect(getAgentServerSessionApiKey()).toBe("saved-session-key");
|
||||
});
|
||||
|
||||
it("loads public skills by default when VITE_LOAD_PUBLIC_SKILLS is unset", () => {
|
||||
vi.stubEnv("VITE_LOAD_PUBLIC_SKILLS", "");
|
||||
|
||||
@@ -137,7 +107,7 @@ describe("isAuthRequired", () => {
|
||||
expect(isAuthRequired()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when window.__AGENT_CANVAS_AUTH_REQUIRED__ is set (static binary path)", () => {
|
||||
it("returns true when window.__AGENT_CANVAS_AUTH_REQUIRED__ is set", () => {
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_AUTH_REQUIRED__ = true;
|
||||
@@ -162,13 +132,15 @@ describe("isAuthRequiredAndMissing", () => {
|
||||
expect(isAuthRequiredAndMissing()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when VITE_AUTH_REQUIRED is true and no key is configured", () => {
|
||||
it("returns true when VITE_AUTH_REQUIRED is true and no key is baked in", () => {
|
||||
vi.stubEnv("VITE_AUTH_REQUIRED", "true");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
|
||||
expect(isAuthRequiredAndMissing()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true via window flag when no key is configured", () => {
|
||||
it("returns true via window flag when no key is baked in", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_AUTH_REQUIRED__ = true;
|
||||
@@ -176,28 +148,6 @@ describe("isAuthRequiredAndMissing", () => {
|
||||
expect(isAuthRequiredAndMissing()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when VITE_AUTH_REQUIRED is true but a key exists in localStorage", () => {
|
||||
vi.stubEnv("VITE_AUTH_REQUIRED", "true");
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "stored-key",
|
||||
});
|
||||
|
||||
expect(isAuthRequiredAndMissing()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when window flag is set but a key exists in localStorage", () => {
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_AUTH_REQUIRED__ = true;
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "stored-key",
|
||||
});
|
||||
|
||||
expect(isAuthRequiredAndMissing()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when VITE_AUTH_REQUIRED is true but VITE_SESSION_API_KEY is baked in", () => {
|
||||
vi.stubEnv("VITE_AUTH_REQUIRED", "true");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "baked-key");
|
||||
@@ -211,63 +161,3 @@ describe("isAuthRequiredAndMissing", () => {
|
||||
expect(isAuthRequiredAndMissing()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncBakedSessionApiKey", () => {
|
||||
it("overwrites a stale stored key when VITE_SESSION_API_KEY differs", () => {
|
||||
// Simulate Run 1: the onboarding or settings page stored the old key.
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "old-key",
|
||||
});
|
||||
|
||||
// Run 2: dev scripts restart with a new key.
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "new-key");
|
||||
|
||||
syncBakedSessionApiKey();
|
||||
|
||||
// The stored config must reflect the new baked key.
|
||||
expect(getAgentServerSessionApiKey()).toBe("new-key");
|
||||
const raw = JSON.parse(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY) ?? "{}",
|
||||
);
|
||||
expect(raw.sessionApiKey).toBe("new-key");
|
||||
});
|
||||
|
||||
it("does nothing when the stored key already matches the baked key", () => {
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "same-key",
|
||||
});
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "same-key");
|
||||
|
||||
syncBakedSessionApiKey();
|
||||
|
||||
expect(getAgentServerSessionApiKey()).toBe("same-key");
|
||||
});
|
||||
|
||||
it("does nothing when no key is stored (empty localStorage)", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "baked-key");
|
||||
|
||||
syncBakedSessionApiKey();
|
||||
|
||||
// Falls through to VITE_SESSION_API_KEY as before.
|
||||
expect(getAgentServerSessionApiKey()).toBe("baked-key");
|
||||
// Should NOT have written to localStorage since there was nothing stale.
|
||||
expect(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing in public mode (no VITE_SESSION_API_KEY)", () => {
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "user-pasted-key",
|
||||
});
|
||||
|
||||
// No VITE_SESSION_API_KEY → public mode or static build without injection.
|
||||
syncBakedSessionApiKey();
|
||||
|
||||
// The user-pasted key must be preserved.
|
||||
expect(getAgentServerSessionApiKey()).toBe("user-pasted-key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -630,17 +630,52 @@ describe("AgentServerConversationService", () => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
it("switches the conversation by profile name when a conversationId is provided", async () => {
|
||||
mockSwitchProfile.mockResolvedValue(undefined);
|
||||
it("switches an active conversation with the full encrypted profile config", async () => {
|
||||
mockGetProfile.mockResolvedValue({
|
||||
name: "haiku",
|
||||
config: {
|
||||
model: "litellm_proxy/claude-haiku-4-5",
|
||||
api_key: "encrypted-key",
|
||||
base_url: "https://llm-proxy.app.all-hands.dev/",
|
||||
},
|
||||
api_key_set: true,
|
||||
});
|
||||
mockSwitchLLM.mockResolvedValue(undefined);
|
||||
|
||||
await AgentServerConversationService.switchProfile("conv-1", "haiku");
|
||||
|
||||
expect(mockSwitchProfile).toHaveBeenCalledWith("conv-1", "haiku");
|
||||
expect(mockGetProfile).toHaveBeenCalledWith("haiku", {
|
||||
exposeSecrets: "encrypted",
|
||||
});
|
||||
expect(mockSwitchLLM).toHaveBeenCalledWith(
|
||||
"conv-1",
|
||||
expect.objectContaining({
|
||||
model: "litellm_proxy/claude-haiku-4-5",
|
||||
api_key: "encrypted-key",
|
||||
base_url: "https://llm-proxy.app.all-hands.dev/",
|
||||
usage_id: expect.stringMatching(/^profile:haiku:/),
|
||||
}),
|
||||
);
|
||||
// Per-convo path: global default is left untouched and profile secrets are
|
||||
// never fetched into the UI.
|
||||
// only fetched as encrypted values for direct round-trip to switch_llm.
|
||||
expect(mockActivateProfile).not.toHaveBeenCalled();
|
||||
expect(mockGetProfile).not.toHaveBeenCalled();
|
||||
expect(mockSwitchProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces encrypted profile export failures instead of using the stale profile switch path", async () => {
|
||||
const error = new Error("No cipher");
|
||||
mockGetProfile.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
AgentServerConversationService.switchProfile("conv-1", "haiku"),
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(mockGetProfile).toHaveBeenCalledWith("haiku", {
|
||||
exposeSecrets: "encrypted",
|
||||
});
|
||||
expect(mockSwitchProfile).not.toHaveBeenCalled();
|
||||
expect(mockSwitchLLM).not.toHaveBeenCalled();
|
||||
expect(mockActivateProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the profile globally when called without a conversationId", async () => {
|
||||
@@ -696,7 +731,7 @@ describe("AgentServerConversationService", () => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -706,7 +741,7 @@ describe("AgentServerConversationService", () => {
|
||||
|
||||
it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
id: "task-1",
|
||||
status: "WORKING",
|
||||
@@ -731,13 +766,13 @@ describe("AgentServerConversationService", () => {
|
||||
);
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const upstream = body as {
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
expect(upstream.path).toBe("/api/v1/app-conversations");
|
||||
expect(upstream.body).toMatchObject({
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations`,
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { data: Record<string, unknown> }).data).toMatchObject({
|
||||
parent_conversation_id: "parent-conv-1",
|
||||
agent_type: "plan",
|
||||
sandbox_id: "sandbox-9",
|
||||
@@ -746,7 +781,7 @@ describe("AgentServerConversationService", () => {
|
||||
|
||||
it("routes readConversationFile to the cloud file endpoint with the file_path query param", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: "# PLAN content" });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: "# PLAN content" });
|
||||
|
||||
// Act
|
||||
const content =
|
||||
@@ -756,11 +791,13 @@ describe("AgentServerConversationService", () => {
|
||||
|
||||
// Assert
|
||||
expect(content).toBe("# PLAN content");
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const upstream = body as { method: string; path: string };
|
||||
expect(upstream.method).toBe("GET");
|
||||
expect(upstream.path).toBe(
|
||||
"/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md",
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { url: string }).url).toBe(
|
||||
`${cloudBackend.host}/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
getActiveBackend,
|
||||
getEffectiveLocalBackend,
|
||||
NO_BACKEND_ID,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
subscribeActiveBackend,
|
||||
@@ -37,7 +39,21 @@ const localBackend: Backend = {
|
||||
};
|
||||
|
||||
describe("active-store", () => {
|
||||
it("seeds the registry with a default local backend on first read and uses it as the active backend", () => {
|
||||
it("uses the no-backend sentinel when no backend details are available", () => {
|
||||
window.localStorage.clear();
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
__resetActiveStoreForTests();
|
||||
|
||||
const { backend, orgId } = getActiveBackend();
|
||||
expect(backend.id).toBe(NO_BACKEND_ID);
|
||||
expect(orgId).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the registry with a default local backend when host and API key are available", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
|
||||
__resetActiveStoreForTests();
|
||||
|
||||
const { backend, orgId } = getActiveBackend();
|
||||
expect(backend.id).toBe(DEFAULT_LOCAL_BACKEND_ID);
|
||||
expect(backend.kind).toBe("local");
|
||||
@@ -62,13 +78,25 @@ describe("active-store", () => {
|
||||
expect(getActiveBackend().orgId).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a synthetic env-derived backend when the registry has no local entry", () => {
|
||||
it("falls back to the first registered backend when the registry has no local entry", () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection(null);
|
||||
|
||||
const { backend } = getActiveBackend();
|
||||
expect(backend.kind).toBe("local");
|
||||
expect(backend.id).toBe(DEFAULT_LOCAL_BACKEND_ID);
|
||||
expect(getActiveBackend().backend).toEqual(cloudBackend);
|
||||
});
|
||||
|
||||
it("uses the active local backend as the effective local backend", () => {
|
||||
setRegisteredBackends([localBackend, cloudBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
|
||||
expect(getEffectiveLocalBackend()).toEqual(localBackend);
|
||||
});
|
||||
|
||||
it("does not borrow a registered local backend when the active backend is cloud", () => {
|
||||
setRegisteredBackends([localBackend, cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
|
||||
expect(getEffectiveLocalBackend()).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies subscribers when selection changes", () => {
|
||||
|
||||
@@ -43,19 +43,60 @@ describe("backend-registry storage", () => {
|
||||
expect(readStoredBackends()).toEqual([]);
|
||||
});
|
||||
|
||||
it("seeds the default Local backend when storage key is missing", () => {
|
||||
it("does not seed the default Local backend when launcher details are missing", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
|
||||
|
||||
expect(readStoredBackends()).toEqual([]);
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the default Local backend when host and API key are available", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
|
||||
const result = readStoredBackends();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
|
||||
// Persists the seed so a subsequent read returns the same entry.
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "fresh-session-key",
|
||||
kind: "local",
|
||||
});
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull();
|
||||
expect(readStoredBackends()).toEqual(result);
|
||||
});
|
||||
|
||||
it("re-seeds the default Local backend when storage holds an empty array", () => {
|
||||
it("migrates legacy agent-server config into the backend registry on first read", () => {
|
||||
window.localStorage.setItem(
|
||||
"openhands-agent-server-config",
|
||||
JSON.stringify({
|
||||
baseUrl: "localhost:18000/",
|
||||
sessionApiKey: "legacy-session-key",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = readStoredBackends();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: "http://localhost:18000",
|
||||
apiKey: "legacy-session-key",
|
||||
kind: "local",
|
||||
},
|
||||
]);
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem("openhands-agent-server-config"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("re-seeds the default Local backend when storage holds an empty array and launcher details are available", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([]));
|
||||
|
||||
const result = readStoredBackends();
|
||||
@@ -64,7 +105,9 @@ describe("backend-registry storage", () => {
|
||||
expect(result[0]).toMatchObject({ id: "default-local", kind: "local" });
|
||||
});
|
||||
|
||||
it("re-seeds the default Local backend when every stored entry is invalid", () => {
|
||||
it("re-seeds the default Local backend when every stored entry is invalid and launcher details are available", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([{ kind: "cloud" }, "not-an-object"]),
|
||||
@@ -92,7 +135,24 @@ describe("backend-registry storage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("fills a missing API key on the default Local backend from env defaults", () => {
|
||||
it("preserves stored backends without API keys", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
const storedBackend: Backend = {
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: window.location.origin,
|
||||
apiKey: "",
|
||||
kind: "local",
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([storedBackend]),
|
||||
);
|
||||
|
||||
expect(readStoredBackends()).toEqual([storedBackend]);
|
||||
});
|
||||
|
||||
it("syncs a stale default Local API key from env defaults", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
@@ -101,66 +161,7 @@ describe("backend-registry storage", () => {
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: window.location.origin,
|
||||
apiKey: "",
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = readStoredBackends();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem(BACKENDS_STORAGE_KEY)!)[0],
|
||||
).toMatchObject({
|
||||
id: "default-local",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("refreshes a stale API key on the default Local backend from env defaults", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: window.location.origin,
|
||||
apiKey: "stale-session-key",
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = readStoredBackends();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
expect(
|
||||
JSON.parse(window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]")[0],
|
||||
).toMatchObject({
|
||||
id: "default-local",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fill the default Local backend API key after its host is edited", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: "http://127.0.0.1:9999",
|
||||
apiKey: "",
|
||||
apiKey: "stored-session-key",
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
@@ -168,8 +169,71 @@ describe("backend-registry storage", () => {
|
||||
|
||||
expect(readStoredBackends()[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
host: "http://127.0.0.1:9999",
|
||||
apiKey: "",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs a stale default Local API key across localhost and 127.0.0.1", () => {
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "stored-session-key",
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(readStoredBackends()[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "fresh-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a custom backend API key instead of syncing from env defaults", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
const storedBackend: Backend = {
|
||||
id: "custom-local",
|
||||
name: "Custom Local",
|
||||
host: window.location.origin,
|
||||
apiKey: "stored-session-key",
|
||||
kind: "local",
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([storedBackend]),
|
||||
);
|
||||
|
||||
expect(readStoredBackends()[0]).toMatchObject({
|
||||
id: "custom-local",
|
||||
apiKey: "stored-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a user-edited non-loopback default Local backend API key", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
const storedBackend: Backend = {
|
||||
id: "default-local",
|
||||
name: "Edited Local",
|
||||
host: "https://example.com",
|
||||
apiKey: "stored-session-key",
|
||||
kind: "local",
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([storedBackend]),
|
||||
);
|
||||
|
||||
expect(readStoredBackends()[0]).toMatchObject({
|
||||
id: "default-local",
|
||||
host: "https://example.com",
|
||||
apiKey: "stored-session-key",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,8 +32,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService cloud branch", () => {
|
||||
it("createConversation POSTs the cloud payload through the proxy and returns a WORKING task", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
id: "task-123",
|
||||
created_by_user_id: null,
|
||||
@@ -59,31 +59,28 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
// Must go through the local cloud-proxy (not directly to cloud).
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations`,
|
||||
method: "POST",
|
||||
path: "/api/v1/app-conversations",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
|
||||
// cloud payload shape — flat fields, NO encrypted-settings round-trip.
|
||||
expect(proxiedBody.selected_repository).toBe("user/repo");
|
||||
expect(proxiedBody.selected_branch).toBe("main");
|
||||
expect(proxiedBody.git_provider).toBe("github");
|
||||
expect(proxiedBody.title).toBe("Optional title");
|
||||
expect(proxiedBody.initial_message).toEqual({
|
||||
expect(requestBody.selected_repository).toBe("user/repo");
|
||||
expect(requestBody.selected_branch).toBe("main");
|
||||
expect(requestBody.git_provider).toBe("github");
|
||||
expect(requestBody.title).toBe("Optional title");
|
||||
expect(requestBody.initial_message).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "fix the bug" }],
|
||||
});
|
||||
// The local-only encrypted-settings keys must NOT be present.
|
||||
expect(proxiedBody).not.toHaveProperty("agent_settings_encrypted");
|
||||
expect(proxiedBody).not.toHaveProperty("conversation_settings_encrypted");
|
||||
expect(requestBody).not.toHaveProperty("agent_settings_encrypted");
|
||||
expect(requestBody).not.toHaveProperty("conversation_settings_encrypted");
|
||||
|
||||
// The returned task is the upstream task — WORKING, no app_conversation_id yet.
|
||||
expect(result.id).toBe("task-123");
|
||||
@@ -91,8 +88,8 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
expect(result.app_conversation_id).toBeNull();
|
||||
});
|
||||
|
||||
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= through the proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "task-123",
|
||||
@@ -108,14 +105,14 @@ describe("AgentServerConversationService cloud branch", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await AgentServerConversationService.getStartTask("task-123");
|
||||
const result =
|
||||
await AgentServerConversationService.getStartTask("task-123");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`,
|
||||
method: "GET",
|
||||
path: "/api/v1/app-conversations/start-tasks?ids=task-123",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(result?.status).toBe("READY");
|
||||
expect(result?.app_conversation_id).toBe("conv-456");
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,19 +32,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.deleteConversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to the cloud DELETE app-conversations endpoint", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
|
||||
it("calls the cloud DELETE app-conversations endpoint directly", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await AgentServerConversationService.deleteConversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
|
||||
method: "DELETE",
|
||||
path: "/api/v1/app-conversations/conv-abc",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,22 +32,22 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.downloadConversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to the cloud download endpoint with responseType blob and returns the Blob", async () => {
|
||||
it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => {
|
||||
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: zipBlob });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: zipBlob });
|
||||
|
||||
const result = await AgentServerConversationService.downloadConversation("conv-abc");
|
||||
const result =
|
||||
await AgentServerConversationService.downloadConversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body, config] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`,
|
||||
method: "GET",
|
||||
path: "/api/v1/app-conversations/conv-abc/download",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
responseType: "blob",
|
||||
});
|
||||
expect(config).toMatchObject({ responseType: "blob" });
|
||||
expect(result).toBe(zipBlob);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -59,32 +59,31 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("pauseConversation cloud branch", () => {
|
||||
it("routes through /api/cloud-proxy to POST the cloud sandbox pause endpoint", async () => {
|
||||
it("POSTs directly to the cloud sandbox pause endpoint", async () => {
|
||||
vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await pauseConversation("conv-abc");
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`,
|
||||
method: "POST",
|
||||
path: "/api/v1/sandboxes/sandbox-xyz/pause",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws and does not call cloud-proxy when the cloud conversation has no sandbox_id", async () => {
|
||||
it("throws and does not call the cloud API when the cloud conversation has no sandbox_id", async () => {
|
||||
vi.spyOn(
|
||||
AgentServerConversationService,
|
||||
"batchGetAppConversations",
|
||||
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
|
||||
|
||||
await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
expect(axios.request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ const cloudBackend: Backend = {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -30,31 +30,36 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AgentServerConversationService.updateConversationPublicFlag", () => {
|
||||
it("PATCHes /api/v1/app-conversations/{id} via cloud-proxy on a cloud backend", async () => {
|
||||
it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => {
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: { id: "conv-abc", public: true },
|
||||
});
|
||||
|
||||
await AgentServerConversationService.updateConversationPublicFlag("conv-abc", true);
|
||||
await AgentServerConversationService.updateConversationPublicFlag(
|
||||
"conv-abc",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/app-conversations/conv-abc`,
|
||||
method: "PATCH",
|
||||
path: "/api/v1/app-conversations/conv-abc",
|
||||
body: { public: true },
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: { public: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects without calling the proxy when the active backend is local", async () => {
|
||||
it("rejects without calling the cloud API when the active backend is local", async () => {
|
||||
// Default state after reset is the bundled local backend.
|
||||
await expect(
|
||||
AgentServerConversationService.updateConversationPublicFlag("conv-abc", true),
|
||||
AgentServerConversationService.updateConversationPublicFlag(
|
||||
"conv-abc",
|
||||
true,
|
||||
),
|
||||
).rejects.toThrow(/cloud backend/);
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
expect(axios.request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,11 +71,12 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
|
||||
|
||||
// Act
|
||||
const result = await AgentServerConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
const result =
|
||||
await AgentServerConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
@@ -114,11 +115,12 @@ describe("AgentServerConversationService.getRuntimeConversation", () => {
|
||||
"http://192.168.1.42:8888/api/conversations/conv-abc";
|
||||
|
||||
// Act
|
||||
const result = await AgentServerConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
const result =
|
||||
await AgentServerConversationService.getRuntimeConversation(
|
||||
"conv-abc",
|
||||
conversationUrl,
|
||||
"session-xyz",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,7 +38,7 @@ afterEach(() => {
|
||||
describe("getCloudRepositoryBranches", () => {
|
||||
it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValueOnce(emptyBranchPage);
|
||||
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
|
||||
|
||||
// Act
|
||||
await getCloudRepositoryBranches({
|
||||
@@ -47,14 +47,14 @@ describe("getCloudRepositoryBranches", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const path = (body as { path: string }).path;
|
||||
expect(path).toMatch(/[?&]query=(&|$)/);
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
expect(url).toMatch(/[?&]query=(&|$)/);
|
||||
});
|
||||
|
||||
it("forwards a non-empty query parameter when searching branches", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValueOnce(emptyBranchPage);
|
||||
vi.mocked(axios.request).mockResolvedValueOnce(emptyBranchPage);
|
||||
|
||||
// Act
|
||||
await getCloudRepositoryBranches({
|
||||
@@ -64,8 +64,8 @@ describe("getCloudRepositoryBranches", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const path = (body as { path: string }).path;
|
||||
expect(path).toContain("query=feature%2Flogin");
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
expect(url).toContain("query=feature%2Flogin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -31,10 +31,10 @@ afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("cloud organization /me via local proxy", () => {
|
||||
it("calls /api/organizations/{orgId}/me through the proxy and returns user_id", async () => {
|
||||
describe("cloud organization /me", () => {
|
||||
it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => {
|
||||
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
org_id: orgId,
|
||||
user_id: orgId,
|
||||
@@ -45,12 +45,11 @@ describe("cloud organization /me via local proxy", () => {
|
||||
|
||||
const result = await getCloudOrganizationMe(orgId);
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/organizations/${orgId}/me`,
|
||||
method: "GET",
|
||||
path: `/api/organizations/${orgId}/me`,
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(result).toEqual({ orgId, userId: orgId });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import axios from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
getCloudOrganizations,
|
||||
getCurrentCloudApiKey,
|
||||
@@ -18,16 +22,20 @@ const cloudBackend: Backend = {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([]);
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(axios.post).mockReset();
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
describe("cloud organization-service via local proxy", () => {
|
||||
it("getCloudOrganizations posts the right envelope to the local proxy and returns normalized data", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
describe("cloud organization-service", () => {
|
||||
it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
items: [{ id: "org-1", name: "Personal" }],
|
||||
current_org_id: "org-1",
|
||||
@@ -36,28 +44,15 @@ describe("cloud organization-service via local proxy", () => {
|
||||
|
||||
const result = await getCloudOrganizations(cloudBackend);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledOnce();
|
||||
const [url, body, options] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(axios.request).toHaveBeenCalledOnce();
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
|
||||
// Should target the bundled local agent-server, not the cloud host.
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(url).not.toContain("app.all-hands.dev");
|
||||
|
||||
// The envelope carries the cloud host + path + bearer header.
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/organizations`,
|
||||
method: "GET",
|
||||
path: "/api/organizations",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
|
||||
// The outer request to the local agent-server uses the local
|
||||
// X-Session-API-Key auth, NOT the cloud bearer.
|
||||
expect(
|
||||
(options as { headers?: Record<string, string> } | undefined)?.headers ??
|
||||
{},
|
||||
).not.toHaveProperty("Authorization");
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [{ id: "org-1", name: "Personal" }],
|
||||
currentOrgId: "org-1",
|
||||
@@ -65,7 +60,7 @@ describe("cloud organization-service via local proxy", () => {
|
||||
});
|
||||
|
||||
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
id: "key-1",
|
||||
name: "k",
|
||||
@@ -77,8 +72,12 @@ describe("cloud organization-service via local proxy", () => {
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect((body as { path: string }).path).toBe("/api/keys/current");
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/keys/current`,
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
|
||||
});
|
||||
|
||||
@@ -87,7 +86,7 @@ describe("cloud organization-service via local proxy", () => {
|
||||
response: { status: 400 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.post).mockRejectedValueOnce(error);
|
||||
vi.mocked(axios.request).mockRejectedValueOnce(error);
|
||||
|
||||
const result = await getCurrentCloudApiKey(cloudBackend);
|
||||
|
||||
@@ -99,7 +98,7 @@ describe("cloud organization-service via local proxy", () => {
|
||||
response: { status: 401 },
|
||||
});
|
||||
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
|
||||
vi.mocked(axios.post).mockRejectedValueOnce(error);
|
||||
vi.mocked(axios.request).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
@@ -10,14 +10,6 @@ import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local",
|
||||
host: "http://localhost:9000",
|
||||
apiKey: "local-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const cloudPersonal: Backend = {
|
||||
id: "cloud-personal",
|
||||
name: "Production - Personal",
|
||||
@@ -37,14 +29,14 @@ const cloudAcme: Backend = {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
describe("callCloudProxy X-Org-Id injection", () => {
|
||||
@@ -52,7 +44,7 @@ describe("callCloudProxy X-Org-Id injection", () => {
|
||||
// Arrange — active selection points at the cloud backend with a
|
||||
// resolved orgId. This is the steady-state case after the user picks
|
||||
// an org row in the BackendSelector.
|
||||
setRegisteredBackends([localBackend, cloudPersonal]);
|
||||
setRegisteredBackends([cloudPersonal]);
|
||||
setActiveSelection({
|
||||
backendId: cloudPersonal.id,
|
||||
orgId: "org-personal-uuid",
|
||||
@@ -65,12 +57,16 @@ describe("callCloudProxy X-Org-Id injection", () => {
|
||||
path: "/api/v1/app-conversations/search",
|
||||
});
|
||||
|
||||
// Assert — the upstream envelope carries the X-Org-Id of the active
|
||||
// selection so the cloud backend can scope this request to the user's
|
||||
// locally-chosen org without depending on user.current_org_id.
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
// Assert — the request carries the X-Org-Id of the active selection so the
|
||||
// cloud backend can scope this request to the user's locally-chosen org
|
||||
// without depending on user.current_org_id.
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudPersonal.host}/api/v1/app-conversations/search`,
|
||||
method: "GET",
|
||||
});
|
||||
expect(
|
||||
(body as { headers: Record<string, string> }).headers["X-Org-Id"],
|
||||
(config as { headers: Record<string, string> }).headers["X-Org-Id"],
|
||||
).toBe("org-personal-uuid");
|
||||
});
|
||||
|
||||
@@ -93,9 +89,9 @@ describe("callCloudProxy X-Org-Id injection", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(
|
||||
(body as { headers: Record<string, string> }).headers,
|
||||
(config as { headers: Record<string, string> }).headers,
|
||||
).not.toHaveProperty("X-Org-Id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,14 +23,14 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: [] });
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
describe("batchGetCloudSandboxes", () => {
|
||||
@@ -44,13 +44,13 @@ describe("batchGetCloudSandboxes", () => {
|
||||
// Act
|
||||
await batchGetCloudSandboxes(ids);
|
||||
|
||||
// Assert — the cloud-proxy envelope encodes a GET against
|
||||
// /api/v1/sandboxes?id=sandbox-a&id=sandbox-b on the cloud backend.
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const upstream = body as { method: string; path: string };
|
||||
expect(upstream.method).toBe("GET");
|
||||
expect(upstream.path).toBe(
|
||||
"/api/v1/sandboxes?id=sandbox-a&id=sandbox-b",
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((config as { url: string }).url).toBe(
|
||||
`${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,8 +32,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SecretsService against cloud backend", () => {
|
||||
it("paginates getSecrets through /api/cloud-proxy and returns the merged list", async () => {
|
||||
vi.mocked(axios.post)
|
||||
it("paginates getSecrets directly and returns the merged list", async () => {
|
||||
vi.mocked(axios.request)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
@@ -52,27 +52,26 @@ describe("SecretsService against cloud backend", () => {
|
||||
|
||||
const secrets = await SecretsService.getSecrets();
|
||||
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(firstBody).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(firstConfig).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((firstBody as { path: string }).path).toMatch(
|
||||
/^\/api\/v1\/secrets\/search\?/,
|
||||
expect((firstConfig as { url: string }).url).toMatch(
|
||||
/^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/,
|
||||
);
|
||||
expect((firstBody as { path: string }).path).not.toContain("page_id=");
|
||||
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
|
||||
|
||||
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
|
||||
expect((secondBody as { path: string }).path).toContain("page_id=BETA");
|
||||
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
|
||||
expect((secondConfig as { url: string }).url).toContain("page_id=BETA");
|
||||
|
||||
expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]);
|
||||
});
|
||||
|
||||
it("creates a secret via POST /api/v1/secrets through the cloud proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
it("creates a secret via direct POST /api/v1/secrets", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.createSecret(
|
||||
"OPENAI_API_KEY",
|
||||
@@ -80,13 +79,12 @@ describe("SecretsService against cloud backend", () => {
|
||||
"OpenAI key",
|
||||
);
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets`,
|
||||
method: "POST",
|
||||
path: "/api/v1/secrets",
|
||||
body: {
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: {
|
||||
name: "OPENAI_API_KEY",
|
||||
value: "sk-test",
|
||||
description: "OpenAI key",
|
||||
@@ -95,32 +93,30 @@ describe("SecretsService against cloud backend", () => {
|
||||
});
|
||||
|
||||
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
// The form/hook calls updateSecret(secretToEdit, newName, description).
|
||||
await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets/OLD_NAME`,
|
||||
method: "PUT",
|
||||
path: "/api/v1/secrets/OLD_NAME",
|
||||
body: { name: "NEW_NAME", description: "renamed" },
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
data: { name: "NEW_NAME", description: "renamed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a secret via DELETE /api/v1/secrets/{id} through the cloud proxy", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} });
|
||||
it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => {
|
||||
vi.mocked(axios.request).mockResolvedValueOnce({ data: {} });
|
||||
|
||||
await SecretsService.deleteSecret("token with space");
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/secrets/token%20with%20space`,
|
||||
method: "DELETE",
|
||||
path: "/api/v1/secrets/token%20with%20space",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -35,9 +35,9 @@ afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("cloud settings via local proxy", () => {
|
||||
describe("cloud settings", () => {
|
||||
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: {
|
||||
llm_model: "anthropic/claude-3-5-sonnet",
|
||||
llm_base_url: "https://api.anthropic.com",
|
||||
@@ -52,13 +52,11 @@ describe("cloud settings via local proxy", () => {
|
||||
|
||||
const result = await fetchCloudSettings();
|
||||
|
||||
// Outer hop must be local proxy.
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
method: "GET",
|
||||
path: "/api/v1/settings",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
|
||||
// provider_tokens_set must round-trip — it's what drives
|
||||
@@ -82,7 +80,7 @@ describe("cloud settings via local proxy", () => {
|
||||
});
|
||||
|
||||
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
const agentDiff = {
|
||||
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
|
||||
@@ -95,25 +93,24 @@ describe("cloud settings via local proxy", () => {
|
||||
conversation_settings_diff: conversationDiff,
|
||||
});
|
||||
|
||||
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(url).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(body).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
method: "POST",
|
||||
path: "/api/v1/settings",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: agentDiff,
|
||||
conversation_settings_diff: conversationDiff,
|
||||
});
|
||||
expect(proxiedBody).not.toHaveProperty("agent_settings");
|
||||
expect(proxiedBody).not.toHaveProperty("conversation_settings");
|
||||
expect(requestBody).not.toHaveProperty("agent_settings");
|
||||
expect(requestBody).not.toHaveProperty("conversation_settings");
|
||||
});
|
||||
|
||||
it("SettingsService.saveSettings forwards disabled_skills to the cloud proxy when active backend is cloud", async () => {
|
||||
// Arrange: cloud backend already active via beforeEach; mock proxy response.
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => {
|
||||
// Arrange: cloud backend already active via beforeEach; mock cloud response.
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act: save a skills-only update — previously this short-circuited and
|
||||
// sent nothing at all, leaving the toggle un-persisted.
|
||||
@@ -121,21 +118,22 @@ describe("cloud settings via local proxy", () => {
|
||||
disabled_skills: ["SSH Microagent"],
|
||||
});
|
||||
|
||||
// Assert: a single proxied POST /api/v1/settings reached the wire with
|
||||
// disabled_skills as a top-level field on the upstream body.
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(1);
|
||||
const [, envelope] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(envelope).toMatchObject({
|
||||
// Assert: a single POST /api/v1/settings reached the wire with
|
||||
// disabled_skills as a top-level field.
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(1);
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(config).toMatchObject({
|
||||
url: `${cloudBackend.host}/api/v1/settings`,
|
||||
method: "POST",
|
||||
path: "/api/v1/settings",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((envelope as { body: Record<string, unknown> }).body).toEqual({
|
||||
expect((config as { data: Record<string, unknown> }).data).toEqual({
|
||||
disabled_skills: ["SSH Microagent"],
|
||||
});
|
||||
});
|
||||
|
||||
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
await saveCloudSettings({
|
||||
agent_settings_diff: {
|
||||
@@ -144,9 +142,9 @@ describe("cloud settings via local proxy", () => {
|
||||
conversation_settings_diff: {},
|
||||
});
|
||||
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
},
|
||||
@@ -157,7 +155,7 @@ describe("cloud settings via local proxy", () => {
|
||||
describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => {
|
||||
it("strips a null agent_context while preserving sibling agent settings", async () => {
|
||||
// Arrange: the cloud rejects agent_context: null against OpenHandsAgentSettings.
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
@@ -168,9 +166,9 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
});
|
||||
|
||||
// Assert: agent_context never reaches the wire, but the real llm change does.
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
expect(requestBody).toEqual({
|
||||
agent_settings_diff: {
|
||||
llm: { model: "anthropic/claude-sonnet-4-20250514" },
|
||||
},
|
||||
@@ -179,7 +177,7 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
|
||||
it("preserves a null mcp_config so clearing MCP config still round-trips", async () => {
|
||||
// Arrange: mcp_config: null is an intentional "clear" signal, not an error.
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
@@ -187,14 +185,14 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
});
|
||||
|
||||
// Assert: the null mcp_config must survive (don't over-strip nulls).
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({ agent_settings_diff: { mcp_config: null } });
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } });
|
||||
});
|
||||
|
||||
it("omits agent_settings_diff when agent_context: null is its only key", async () => {
|
||||
// Arrange
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
||||
|
||||
// Act
|
||||
await saveCloudSettings({
|
||||
@@ -202,8 +200,8 @@ describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () =>
|
||||
});
|
||||
|
||||
// Assert: nothing is left to send, so no agent_settings_diff goes on the wire.
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const proxiedBody = (body as { body: Record<string, unknown> }).body;
|
||||
expect(proxiedBody).toEqual({});
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const requestBody = (config as { data: Record<string, unknown> }).data;
|
||||
expect(requestBody).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -32,8 +32,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SkillsService.getSkills against cloud backend", () => {
|
||||
it("paginates /api/v1/skills/search via the local cloud-proxy and returns the merged list", async () => {
|
||||
vi.mocked(axios.post)
|
||||
it("paginates /api/v1/skills/search directly and returns the merged list", async () => {
|
||||
vi.mocked(axios.request)
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
items: [
|
||||
@@ -57,21 +57,20 @@ describe("SkillsService.getSkills against cloud backend", () => {
|
||||
|
||||
const skills = await SkillsService.getSkills();
|
||||
|
||||
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(axios.request)).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
|
||||
expect(firstBody).toMatchObject({
|
||||
host: cloudBackend.host,
|
||||
const [firstConfig] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
expect(firstConfig).toMatchObject({
|
||||
method: "GET",
|
||||
headers: { Authorization: "Bearer bearer-token" },
|
||||
});
|
||||
expect((firstBody as { path: string }).path).toMatch(
|
||||
/^\/api\/v1\/skills\/search\?/,
|
||||
expect((firstConfig as { url: string }).url).toMatch(
|
||||
/^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/,
|
||||
);
|
||||
expect((firstBody as { path: string }).path).not.toContain("page_id=");
|
||||
expect((firstConfig as { url: string }).url).not.toContain("page_id=");
|
||||
|
||||
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
|
||||
expect((secondBody as { path: string }).path).toContain("page_id=beta");
|
||||
const [secondConfig] = vi.mocked(axios.request).mock.calls[1]!;
|
||||
expect((secondConfig as { url: string }).url).toContain("page_id=beta");
|
||||
|
||||
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
|
||||
|
||||
@@ -22,15 +22,15 @@ beforeEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([cloudBackend]);
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
vi.mocked(axios.request).mockReset();
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
data: { items: [], next_page_id: null },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.request).mockReset();
|
||||
});
|
||||
|
||||
describe("getCloudSuggestedTasks", () => {
|
||||
@@ -39,10 +39,10 @@ describe("getCloudSuggestedTasks", () => {
|
||||
await getCloudSuggestedTasks({ limit: 10, pageId: "p2" });
|
||||
|
||||
// Assert
|
||||
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
|
||||
const path = (body as { path: string }).path;
|
||||
expect(path).toContain("/api/v1/git/suggested-tasks/search");
|
||||
expect(path).toContain("limit=10");
|
||||
expect(path).toContain("page_id=p2");
|
||||
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
||||
const url = (config as { url: string }).url;
|
||||
expect(url).toContain("/api/v1/git/suggested-tasks/search");
|
||||
expect(url).toContain("limit=10");
|
||||
expect(url).toContain("page_id=p2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,6 @@ import {
|
||||
DeviceFlowError,
|
||||
} from "../../src/api/device-flow-client";
|
||||
|
||||
// Mock the backend registry to avoid import issues
|
||||
vi.mock("../../src/api/backend-registry/active-store", () => ({
|
||||
getEffectiveLocalBackend: () => ({
|
||||
host: "http://localhost:18000",
|
||||
apiKey: "test-api-key",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/api/backend-registry/auth", () => ({
|
||||
buildAuthHeaders: () => ({ "X-Session-API-Key": "test-api-key" }),
|
||||
}));
|
||||
|
||||
const TEST_HOST_URL = "https://app.all-hands.dev";
|
||||
|
||||
describe("device-flow-client", () => {
|
||||
@@ -91,14 +79,13 @@ describe("device-flow-client", () => {
|
||||
const result = await startDeviceFlow(TEST_HOST_URL);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
// Should call the local proxy endpoint
|
||||
// Should call the cloud endpoint directly.
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:18000/api/cloud-proxy",
|
||||
`${TEST_HOST_URL}/oauth/device/authorize`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Session-API-Key": "test-api-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -120,10 +107,9 @@ describe("device-flow-client", () => {
|
||||
|
||||
await startDeviceFlow(`${TEST_HOST_URL}///`);
|
||||
|
||||
// Verify the proxy body contains the normalized host
|
||||
// Verify the direct request targets the normalized host.
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.host).toBe(TEST_HOST_URL);
|
||||
expect(fetchCall[0]).toBe(`${TEST_HOST_URL}/oauth/device/authorize`);
|
||||
});
|
||||
|
||||
it("throws DeviceFlowError on HTTP error", async () => {
|
||||
@@ -189,14 +175,13 @@ describe("device-flow-client", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockTokenResponse);
|
||||
// Should call the local proxy endpoint
|
||||
// Should call the cloud endpoint directly.
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:18000/api/cloud-proxy",
|
||||
`${TEST_HOST_URL}/oauth/device/token`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Session-API-Key": "test-api-key",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("toAppConversation session_api_key hydration", () => {
|
||||
it("prefers the configured VITE_SESSION_API_KEY over a stale stored default-local apiKey", () => {
|
||||
it("uses the active backend registry apiKey instead of VITE_SESSION_API_KEY", () => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key");
|
||||
|
||||
setRegisteredBackends([
|
||||
@@ -34,6 +34,6 @@ describe("toAppConversation session_api_key hydration", () => {
|
||||
]);
|
||||
|
||||
const conversation = toAppConversation(directInfo("conv-1"));
|
||||
expect(conversation.session_api_key).toBe("fresh-session-key");
|
||||
expect(conversation.session_api_key).toBe("stale-session-key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,8 @@ vi.mock("@openhands/typescript-client/clients", async () => {
|
||||
vi.mock("#/api/agent-server-config", () => ({
|
||||
DEFAULT_WORKING_DIR: "workspace/project",
|
||||
getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"),
|
||||
getAgentServerSessionApiKey: vi.fn(() => null),
|
||||
getBakedSessionApiKey: vi.fn(() => "test-session-key"),
|
||||
getAgentServerSessionApiKey: vi.fn(() => "test-session-key"),
|
||||
getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"),
|
||||
buildConversationWorkingDir: vi.fn(
|
||||
(id: string) => `/state/workspaces/${id.replace(/-/g, "")}`,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -35,4 +45,82 @@ describe("agent-canvas CLI", () => {
|
||||
expect(stdout).toContain("--backend-only");
|
||||
expect(stdout).toContain("--help");
|
||||
});
|
||||
|
||||
it("does not require build/ in --backend-only mode", async () => {
|
||||
const tempRoot = mkdtempSync(resolve(tmpdir(), "agent-canvas-bin-"));
|
||||
const stubBinDir = mkdtempSync(resolve(tmpdir(), "agent-canvas-stub-bin-"));
|
||||
const badSdkDir = mkdtempSync(resolve(tmpdir(), "agent-canvas-bad-sdk-"));
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(tempRoot, "bin"));
|
||||
copyFileSync(
|
||||
resolve(repoRoot, "bin", "agent-canvas.mjs"),
|
||||
resolve(tempRoot, "bin", "agent-canvas.mjs"),
|
||||
);
|
||||
symlinkSync(
|
||||
resolve(repoRoot, "scripts"),
|
||||
resolve(tempRoot, "scripts"),
|
||||
isWindows ? "junction" : "dir",
|
||||
);
|
||||
symlinkSync(
|
||||
resolve(repoRoot, "config"),
|
||||
resolve(tempRoot, "config"),
|
||||
isWindows ? "junction" : "dir",
|
||||
);
|
||||
|
||||
if (isWindows) {
|
||||
writeFileSync(resolve(stubBinDir, "uvx.cmd"), "@exit /b 0\r\n");
|
||||
} else {
|
||||
const uvxPath = resolve(stubBinDir, "uvx");
|
||||
writeFileSync(uvxPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(uvxPath, 0o755);
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[resolve(tempRoot, "bin", "agent-canvas.mjs"), "--backend-only"],
|
||||
{
|
||||
cwd: tempRoot,
|
||||
env: {
|
||||
PATH: `${stubBinDir}${delimiter}${process.env.PATH ?? ""}`,
|
||||
HOME: process.env.HOME ?? "",
|
||||
OH_AGENT_SERVER_LOCAL_PATH: badSdkDir,
|
||||
PORT: "19912",
|
||||
OH_CANVAS_SAFE_BACKEND_PORT: "19910",
|
||||
OH_CANVAS_SAFE_AUTOMATION_PORT: "19911",
|
||||
OH_CANVAS_SAFE_VITE_PORT: "19913",
|
||||
...(isWindows
|
||||
? {
|
||||
PATHEXT: process.env.PATHEXT ?? ".CMD;.EXE;.BAT;.COM",
|
||||
SystemRoot: process.env.SystemRoot ?? "",
|
||||
USERPROFILE: process.env.USERPROFILE ?? "",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
const [code] = await once(child, "exit");
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(output).toContain(
|
||||
"OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package",
|
||||
);
|
||||
expect(output).not.toContain("No build found");
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
rmSync(stubBinDir, { recursive: true, force: true });
|
||||
rmSync(badSdkDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from "#/context/navigation-context";
|
||||
import { AddBackendModal } from "#/components/features/backends/add-backend-modal";
|
||||
|
||||
const getServerInfoMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(function ServerClientMock() {
|
||||
return {
|
||||
getServerInfo: getServerInfoMock,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
navigation?: NavigationContextValue,
|
||||
@@ -33,6 +43,8 @@ function renderWithProviders(
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
getServerInfoMock.mockReset();
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.24.0" });
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
@@ -97,6 +109,8 @@ describe("AddBackendModal – two-column layout", () => {
|
||||
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
);
|
||||
@@ -145,7 +159,7 @@ describe("AddBackendModal – two-column layout", () => {
|
||||
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
@@ -166,6 +180,29 @@ describe("AddBackendModal – two-column layout", () => {
|
||||
expect(active).toEqual({ backendId: added.id, orgId: null });
|
||||
});
|
||||
|
||||
it("keeps the modal open and shows a connection error when the local backend probe fails", async () => {
|
||||
getServerInfoMock.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<AddBackendModal onClose={onClose} />);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("add-backend-name"), "GPU Tunnel");
|
||||
await user.type(
|
||||
screen.getByTestId("add-backend-host"),
|
||||
"https://127.0.0.1:8000",
|
||||
);
|
||||
await user.type(screen.getByTestId("add-backend-api-key"), "session-key");
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
|
||||
expect(await screen.findByTestId("add-backend-error")).toHaveTextContent(
|
||||
"BACKEND$CONNECTION_TEST_FAILED",
|
||||
);
|
||||
expect(screen.getByTestId("add-backend-error")).toHaveTextContent(
|
||||
"Failed to fetch",
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when the header close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
@@ -220,6 +257,14 @@ describe("AddBackendModal – redirect after adding a backend", () => {
|
||||
"http://127.0.0.1:18002",
|
||||
);
|
||||
await user.click(screen.getByTestId("add-backend-submit"));
|
||||
await waitFor(() => {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-backends") ?? "[]",
|
||||
);
|
||||
expect(
|
||||
stored.some((b: { name: string }) => b.name === "Local Extra"),
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -3,10 +3,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
saveAgentServerConfig,
|
||||
} from "#/api/agent-server-config";
|
||||
import { BACKENDS_STORAGE_KEY } from "#/api/backend-registry/storage";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
@@ -120,13 +116,7 @@ describe("ApiKeyEntryScreen", () => {
|
||||
|
||||
// @spec — API key field always starts empty (stale key wipe)
|
||||
it("starts with an empty api key even when localStorage has a stale key", () => {
|
||||
// Seed localStorage with a stale key from a previous session
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "old-stale-key-from-previous-session",
|
||||
});
|
||||
|
||||
// Also seed the backend registry with the stale key
|
||||
// Seed the backend registry with the stale key
|
||||
window.localStorage.setItem(
|
||||
BACKENDS_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
@@ -177,11 +167,11 @@ describe("ApiKeyEntryScreen", () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Key persisted to agent-server-config storage
|
||||
// Key persisted to backend registry storage
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY) ?? "{}",
|
||||
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored.sessionApiKey).toBe("correct-key");
|
||||
expect(stored[0].apiKey).toBe("correct-key");
|
||||
|
||||
// Page reloaded
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
@@ -213,10 +203,10 @@ describe("ApiKeyEntryScreen", () => {
|
||||
"AUTH$INVALID_KEY",
|
||||
);
|
||||
|
||||
// Key NOT persisted
|
||||
expect(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
// Rejected key NOT persisted.
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
|
||||
"wrong-key",
|
||||
);
|
||||
|
||||
// Page NOT reloaded
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
@@ -248,10 +238,10 @@ describe("ApiKeyEntryScreen", () => {
|
||||
expect(statusText).toContain("500");
|
||||
expect(statusText).not.toContain("AUTH$INVALID_KEY");
|
||||
|
||||
// Key NOT persisted
|
||||
expect(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
// Rejected key NOT persisted.
|
||||
expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain(
|
||||
"correct-key",
|
||||
);
|
||||
|
||||
expect(reloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -290,19 +280,13 @@ describe("ApiKeyEntryScreen", () => {
|
||||
});
|
||||
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY) ?? "{}",
|
||||
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored.sessionApiKey).toBe("correct-key");
|
||||
expect(stored[0].apiKey).toBe("correct-key");
|
||||
});
|
||||
|
||||
// @spec — Stale key in localStorage does not contaminate the new key
|
||||
it("persists only the freshly-entered key, not the stale one", async () => {
|
||||
// Seed a stale key from a previous session
|
||||
saveAgentServerConfig({
|
||||
baseUrl: "http://localhost:8000",
|
||||
sessionApiKey: "stale-key-AAAA",
|
||||
});
|
||||
|
||||
getSettingsMock.mockResolvedValueOnce({ llm_model: "test" });
|
||||
|
||||
renderScreen();
|
||||
@@ -322,8 +306,8 @@ describe("ApiKeyEntryScreen", () => {
|
||||
|
||||
// Stored key is the NEW one, not old + new concatenated
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY) ?? "{}",
|
||||
window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
expect(stored.sessionApiKey).toBe("fresh-key-BBBB");
|
||||
expect(stored[0].apiKey).toBe("fresh-key-BBBB");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
} from "#/components/features/backends/environment-switch-overlay";
|
||||
import { ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS } from "#/components/features/backends/environment-switch-store";
|
||||
|
||||
import { ServerClient } from "@openhands/typescript-client/clients";
|
||||
import {
|
||||
ServerClient,
|
||||
SettingsClient,
|
||||
} from "@openhands/typescript-client/clients";
|
||||
import {
|
||||
getCloudOrganizations,
|
||||
getCloudOrganizationMe,
|
||||
@@ -37,6 +40,7 @@ vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(),
|
||||
SettingsClient: vi.fn(),
|
||||
}));
|
||||
|
||||
// Shared seed configs reused across tests.
|
||||
@@ -95,8 +99,14 @@ async function openDropdown() {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(getCloudOrganizations).mockReset();
|
||||
vi.mocked(getCloudOrganizations).mockResolvedValue({
|
||||
items: [],
|
||||
currentOrgId: null,
|
||||
});
|
||||
vi.mocked(getCloudOrganizationMe).mockReset();
|
||||
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
|
||||
orgId: "",
|
||||
@@ -117,11 +127,14 @@ beforeEach(() => {
|
||||
vi.mocked(ServerClient).mockImplementation(function ServerClientMock() {
|
||||
return {
|
||||
getServerInfo: vi.fn().mockResolvedValue({ version: "1.18.0" }),
|
||||
// The dropdown only invokes getServerInfo on the returned client;
|
||||
// the rest of the ServerClient surface is unused here, so a
|
||||
// partial cast is sufficient.
|
||||
} as unknown as ServerClient;
|
||||
});
|
||||
vi.mocked(SettingsClient).mockReset();
|
||||
vi.mocked(SettingsClient).mockImplementation(function SettingsClientMock() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsClient;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -140,6 +153,7 @@ afterEach(async () => {
|
||||
});
|
||||
}
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllEnvs();
|
||||
__resetActiveStoreForTests();
|
||||
__resetEnvironmentSwitchOverlayForTests();
|
||||
});
|
||||
@@ -408,49 +422,52 @@ describe("BackendSelector", () => {
|
||||
landingRoute: "/conversations",
|
||||
expectRedirect: false,
|
||||
},
|
||||
])("$name", async ({ startPath, startRoute, landingRoute, expectRedirect }) => {
|
||||
function StartRoute() {
|
||||
return (
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend(SEED_LOCAL_1);
|
||||
}}
|
||||
>
|
||||
<div data-testid="start-route" />
|
||||
<BackendSelector />
|
||||
</TestSeed>
|
||||
);
|
||||
}
|
||||
function LandingRoute() {
|
||||
return <div data-testid="landing-route" />;
|
||||
}
|
||||
const RouterStub = createRoutesStub([
|
||||
{ path: startRoute, Component: StartRoute },
|
||||
{ path: landingRoute, Component: LandingRoute },
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<RouterStub initialEntries={[startPath]} />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
await user.click(screen.getByText("Local"));
|
||||
|
||||
if (expectRedirect) {
|
||||
expect(await screen.findByTestId("landing-route")).toBeInTheDocument();
|
||||
} else {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("start-route")).toBeInTheDocument();
|
||||
])(
|
||||
"$name",
|
||||
async ({ startPath, startRoute, landingRoute, expectRedirect }) => {
|
||||
function StartRoute() {
|
||||
return (
|
||||
<TestSeed
|
||||
onMount={(ctx) => {
|
||||
ctx.addBackend(SEED_LOCAL_1);
|
||||
}}
|
||||
>
|
||||
<div data-testid="start-route" />
|
||||
<BackendSelector />
|
||||
</TestSeed>
|
||||
);
|
||||
}
|
||||
function LandingRoute() {
|
||||
return <div data-testid="landing-route" />;
|
||||
}
|
||||
const RouterStub = createRoutesStub([
|
||||
{ path: startRoute, Component: StartRoute },
|
||||
{ path: landingRoute, Component: LandingRoute },
|
||||
]);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
expect(screen.queryByTestId("landing-route")).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<RouterStub initialEntries={[startPath]} />
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const user = await openDropdown();
|
||||
await user.click(screen.getByText("Local"));
|
||||
|
||||
if (expectRedirect) {
|
||||
expect(await screen.findByTestId("landing-route")).toBeInTheDocument();
|
||||
} else {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("start-route")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("landing-route")).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the environment-switch overlay visible even after the selector unmounts mid-switch", async () => {
|
||||
// Arrange — selector and overlay are rendered in independent trees
|
||||
@@ -575,7 +592,7 @@ describe("BackendSelector", () => {
|
||||
});
|
||||
|
||||
// @spec BM-003 — Fallback on active backend removal
|
||||
it("falls back to the seeded default backend when removing the active backend from manage backends", async () => {
|
||||
it("shows no backend when removing the only active backend from manage backends", async () => {
|
||||
// Pre-seed the registry and active selection in localStorage so the
|
||||
// initial render already reflects `Local 1` as active. Seeding via
|
||||
// `TestSeed.onMount` instead would call `setActive` AFTER the first
|
||||
@@ -621,7 +638,8 @@ describe("BackendSelector", () => {
|
||||
});
|
||||
|
||||
// The active selection is cleared because its target was removed;
|
||||
// the active store then falls back to the seeded default backend.
|
||||
// no registered backend remains, so the selector shows the explicit
|
||||
// unavailable state instead of synthesizing a backend.
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem("openhands-active-backend") ?? "null",
|
||||
);
|
||||
@@ -629,16 +647,18 @@ describe("BackendSelector", () => {
|
||||
|
||||
wrapper = screen.getByTestId("backend-selector");
|
||||
input = wrapper.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("Local");
|
||||
expect(input.value).toBe("BACKEND$NO_BACKEND_AVAILABLE");
|
||||
});
|
||||
|
||||
describe("connection indicator", () => {
|
||||
it("renders one status dot per option, green when the probe succeeds", async () => {
|
||||
vi.mocked(ServerClient).mockImplementation(function ServerClientMock() {
|
||||
return {
|
||||
getServerInfo: vi.fn().mockResolvedValue({ version: "1.18.0" }),
|
||||
} as unknown as ServerClient;
|
||||
});
|
||||
vi.mocked(SettingsClient).mockImplementation(
|
||||
function SettingsClientMock() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsClient;
|
||||
},
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<TestSeed
|
||||
@@ -665,11 +685,13 @@ describe("BackendSelector", () => {
|
||||
});
|
||||
|
||||
it("flips the status dot to red when the local probe fails", async () => {
|
||||
vi.mocked(ServerClient).mockImplementation(function ServerClientMock() {
|
||||
return {
|
||||
getServerInfo: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
} as unknown as ServerClient;
|
||||
});
|
||||
vi.mocked(SettingsClient).mockImplementation(
|
||||
function SettingsClientMock() {
|
||||
return {
|
||||
getSettings: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),
|
||||
} as unknown as SettingsClient;
|
||||
},
|
||||
);
|
||||
|
||||
renderWithProviders(<BackendSelector />);
|
||||
|
||||
|
||||
@@ -18,11 +18,15 @@ import {
|
||||
import { ManageBackendsModal } from "#/components/features/backends/manage-backends-modal";
|
||||
|
||||
const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.18.0" });
|
||||
const getSettingsMock = vi.fn().mockResolvedValue({});
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(function ServerClientMock() {
|
||||
return { getServerInfo: getServerInfoMock };
|
||||
}),
|
||||
SettingsClient: vi.fn(function SettingsClientMock() {
|
||||
return { getSettings: getSettingsMock };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/api/cloud/organization-service.api", () => ({
|
||||
@@ -60,6 +64,10 @@ function TestSeed({
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
getServerInfoMock.mockReset();
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.18.0" });
|
||||
getSettingsMock.mockReset();
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
@@ -84,6 +92,27 @@ describe("ManageBackendsModal", () => {
|
||||
expect(dots.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows invalid API key status when the backend auth probe returns 401", async () => {
|
||||
getSettingsMock.mockRejectedValue(
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
name: "HttpError",
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<ManageBackendsModal onClose={vi.fn()} />);
|
||||
|
||||
const row = await screen.findByTestId("manage-backends-row-Local");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("manage-backends-status-Local"),
|
||||
).toHaveTextContent("AUTH$INVALID_KEY"),
|
||||
);
|
||||
expect(
|
||||
row.querySelector('[data-testid="backend-status-dot"]'),
|
||||
).toHaveAttribute("data-status", "disconnected");
|
||||
});
|
||||
|
||||
it("closes when the header close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -7,8 +7,10 @@ import { LlmNotConfiguredBanner } from "#/components/features/home/llm-not-confi
|
||||
import { NavigationProvider } from "#/context/navigation-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import ProfilesService from "#/api/profiles-service/profiles-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { Settings } from "#/types/settings";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
@@ -44,10 +46,11 @@ function buildConfig(hideLlmSettings = false): WebClientConfig {
|
||||
function ProbedBanner() {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const profiles = useLlmProfiles();
|
||||
return (
|
||||
<>
|
||||
<LlmNotConfiguredBanner />
|
||||
{settings.isFetched && config.isFetched ? (
|
||||
{settings.isFetched && config.isFetched && profiles.isFetched ? (
|
||||
<div data-testid="queries-settled" />
|
||||
) : null}
|
||||
</>
|
||||
@@ -80,6 +83,10 @@ describe("LlmNotConfiguredBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(buildConfig());
|
||||
vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({
|
||||
profiles: [],
|
||||
active_profile: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns the user when no LLM API key is set (the skip-onboarding case)", async () => {
|
||||
@@ -113,6 +120,34 @@ describe("LlmNotConfiguredBanner", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays hidden once an active LLM profile has a saved API key", async () => {
|
||||
// Arrange: the profile endpoint is the source of truth for the active
|
||||
// profile shown in LLM settings, even if the legacy settings flag is stale.
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({ llm_api_key_set: false }),
|
||||
);
|
||||
vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "active-profile",
|
||||
model: "openai/gpt-4.1",
|
||||
base_url: null,
|
||||
api_key_set: true,
|
||||
},
|
||||
],
|
||||
active_profile: "active-profile",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderBanner();
|
||||
await screen.findByTestId("queries-settled");
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.queryByTestId("home-llm-not-configured-banner"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays hidden for ACP agents, which own their LLM and need no key", async () => {
|
||||
// Arrange: ACP agent, no key — must not be nagged.
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
|
||||
@@ -12,14 +12,15 @@ import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
const llmSettingsScreenMock = vi.hoisted(() => vi.fn());
|
||||
const getServerInfoMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Both the backend status badge in the embedded edit form and the
|
||||
// step-1 health probe ride on `useBackendsHealth`, which resolves
|
||||
// server metadata through `ServerClient`.
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(function ServerClientMock() {
|
||||
ServerClient: vi.fn(function ServerClientMock(options?: { host?: string }) {
|
||||
return {
|
||||
getServerInfo: vi.fn().mockResolvedValue({ version: "1.18.0" }),
|
||||
getServerInfo: vi.fn(() => getServerInfoMock(options)),
|
||||
};
|
||||
}),
|
||||
// The always-mounted LLM slide initializes settings hooks even though
|
||||
@@ -97,6 +98,35 @@ vi.mock("#/hooks/query/use-acp-auth-status", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
async function completeBackendStep(user: ReturnType<typeof userEvent.setup>) {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-backend-next")).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
"data-current-step",
|
||||
"1",
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function completeAgentStep(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
"data-current-step",
|
||||
"2",
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
function renderModal(onClose = vi.fn()) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -120,11 +150,20 @@ function renderModal(onClose = vi.fn()) {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
|
||||
__resetActiveStoreForTests();
|
||||
// Clear accumulated spy/mock call history so per-test assertions (the
|
||||
// ACP secret-write checks and the LLM-defaults mock) don't see calls
|
||||
// leaked from a prior test. Covers `llmSettingsScreenMock` too.
|
||||
vi.clearAllMocks();
|
||||
getServerInfoMock.mockReset();
|
||||
getServerInfoMock.mockImplementation((options?: { host?: string }) => {
|
||||
if (options?.host?.startsWith("https://127.0.0.1:8000")) {
|
||||
return Promise.reject(new Error("Failed to fetch"));
|
||||
}
|
||||
return Promise.resolve({ version: "1.18.0" });
|
||||
});
|
||||
// ChooseAgentStep's Next button now persists the selection via
|
||||
// saveSettings before advancing. Stub it so the rest of the flow
|
||||
// (which these tests focus on) isn't gated on a real HTTP call.
|
||||
@@ -138,11 +177,12 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllEnvs();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("OnboardingModal", () => {
|
||||
it("starts on the Choose Agent step with each slide offset by its index", () => {
|
||||
it("starts on the backend setup step with each slide offset by its index", () => {
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
@@ -150,7 +190,7 @@ describe("OnboardingModal", () => {
|
||||
"0",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("onboarding-step-choose-agent"),
|
||||
screen.getByTestId("onboarding-step-check-backend"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("onboarding-slide-0")).toHaveAttribute(
|
||||
@@ -169,6 +209,30 @@ describe("OnboardingModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a connection error when saving an unreachable backend", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.clear(screen.getByTestId("onboarding-backend-host"));
|
||||
await user.type(
|
||||
screen.getByTestId("onboarding-backend-host"),
|
||||
"https://127.0.0.1:8000",
|
||||
);
|
||||
await user.clear(screen.getByTestId("onboarding-backend-api-key"));
|
||||
await user.type(
|
||||
screen.getByTestId("onboarding-backend-api-key"),
|
||||
"session-key",
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-submit"));
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("onboarding-backend-error"),
|
||||
).toHaveTextContent("BACKEND$CONNECTION_TEST_FAILED");
|
||||
expect(screen.getByTestId("onboarding-backend-error")).toHaveTextContent(
|
||||
"Failed to fetch",
|
||||
);
|
||||
});
|
||||
|
||||
it("pre-fills the LLM step with the OpenHands provider", () => {
|
||||
renderModal();
|
||||
|
||||
@@ -186,33 +250,15 @@ describe("OnboardingModal", () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Step 0 → 1. ChooseAgentStep now does an async save before
|
||||
// advancing, so the modal can take a beat to flip steps while
|
||||
// SayHello/CheckBackend queries are still settling on the four
|
||||
// mounted slides. Bump the default 1s waitFor timeout.
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
"data-current-step",
|
||||
"1",
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
// Step 0 → 1. Once the backend health probe resolves, step 0's Next is enabled.
|
||||
await completeBackendStep(user);
|
||||
expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
|
||||
// Once the backend health probe resolves, step 1's Next is enabled.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("onboarding-backend-next")).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
"data-current-step",
|
||||
"2",
|
||||
);
|
||||
// Step 1 → 2. ChooseAgentStep does an async save before advancing.
|
||||
await completeAgentStep(user);
|
||||
expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
@@ -256,15 +302,8 @@ describe("OnboardingModal", () => {
|
||||
// Arrange: render the modal and walk through to the LLM step.
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("onboarding-backend-next"),
|
||||
).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeBackendStep(user);
|
||||
await completeAgentStep(user);
|
||||
// Wait for the LLM slide to become the active one before querying
|
||||
// by role — otherwise the heading is `aria-hidden` from inside a
|
||||
// not-yet-active slide and getByRole filters it out.
|
||||
@@ -300,16 +339,9 @@ describe("OnboardingModal", () => {
|
||||
|
||||
// Pick Gemini CLI: its key/base-URL come from the SDK registry like the
|
||||
// other providers, so the slide shows the GEMINI_API_KEY field.
|
||||
await completeBackendStep(user);
|
||||
await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli"));
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("onboarding-backend-next"),
|
||||
).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeAgentStep(user);
|
||||
|
||||
// Lands on slide 2 (the ACP step) — not jumped past to Say Hello.
|
||||
await waitFor(
|
||||
@@ -348,16 +380,9 @@ describe("OnboardingModal", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Pick Claude Code → Check Backend.
|
||||
await completeBackendStep(user);
|
||||
await user.click(screen.getByTestId("onboarding-agent-option-claude-code"));
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("onboarding-backend-next"),
|
||||
).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeAgentStep(user);
|
||||
|
||||
// Slide 2 is the ACP credentials step (not skipped), so the flow keeps
|
||||
// all 4 progress segments and slide 2 — not Say Hello — is now active.
|
||||
@@ -415,16 +440,9 @@ describe("OnboardingModal", () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await completeBackendStep(user);
|
||||
await user.click(screen.getByTestId("onboarding-agent-option-codex"));
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("onboarding-backend-next"),
|
||||
).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeAgentStep(user);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
@@ -452,15 +470,8 @@ describe("OnboardingModal", () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("onboarding-backend-next"),
|
||||
).not.toBeDisabled(),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeBackendStep(user);
|
||||
await completeAgentStep(user);
|
||||
await user.click(screen.getByTestId("onboarding-llm-next"));
|
||||
|
||||
const helloInput = screen.getByTestId(
|
||||
@@ -478,19 +489,8 @@ describe("OnboardingModal", () => {
|
||||
renderModal(onClose);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-agent-next"));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("onboarding-modal")).toHaveAttribute(
|
||||
"data-current-step",
|
||||
"1",
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("onboarding-backend-next")).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId("onboarding-backend-next"));
|
||||
await completeBackendStep(user);
|
||||
await completeAgentStep(user);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute(
|
||||
"data-active",
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as useLlmProfilesHook from "#/hooks/query/use-llm-profiles";
|
||||
import * as useActivateLlmProfileHook from "#/hooks/mutation/use-activate-llm-profile";
|
||||
import * as useSaveLlmProfileHook from "#/hooks/mutation/use-save-llm-profile";
|
||||
import ProfilesService from "#/api/profiles-service/profiles-service.api";
|
||||
import { OPENHANDS_LLM_PROXY_BASE_URL } from "#/utils/openhands-llm";
|
||||
|
||||
vi.mock("#/hooks/query/use-llm-profiles");
|
||||
vi.mock("#/hooks/mutation/use-activate-llm-profile");
|
||||
@@ -541,11 +542,11 @@ describe("LlmSettingsLocalView", () => {
|
||||
});
|
||||
|
||||
describe("Basic tab save", () => {
|
||||
it("omits base_url so an OpenHands model round-trips on reload", async () => {
|
||||
it("persists the OpenHands proxy base_url for OpenHands models", async () => {
|
||||
// Arrange — a profile whose stored config pairs an OpenHands model with a
|
||||
// stale, non-proxy base_url. Persisting that base_url is exactly what
|
||||
// makes the provider reload as "litellm_proxy" with an empty model; the
|
||||
// Basic tab must drop it so the backend re-derives the All-Hands proxy.
|
||||
// stale, non-proxy base_url. Persisting that stale URL is wrong, but
|
||||
// older local agent-server builds do not derive the All-Hands proxy when
|
||||
// base_url is omitted, so the Basic tab must save the proxy explicitly.
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(ProfilesService.getProfile).mockResolvedValue({
|
||||
name: "gpt-4-profile",
|
||||
@@ -574,12 +575,12 @@ describe("LlmSettingsLocalView", () => {
|
||||
});
|
||||
await user.click(screen.getByTestId("save-profile-btn"));
|
||||
|
||||
// Assert — the saved LLM config keeps the OpenHands model but no longer
|
||||
// carries the stale base_url.
|
||||
// Assert — the saved LLM config keeps the OpenHands model and replaces
|
||||
// the stale base_url with the proxy required for litellm_proxy models.
|
||||
await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled());
|
||||
const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm;
|
||||
expect(savedLlm.model).toBe("openhands/claude-opus-4-5-20251101");
|
||||
expect(savedLlm).not.toHaveProperty("base_url");
|
||||
expect(savedLlm.base_url).toBe(OPENHANDS_LLM_PROXY_BASE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
NO_BACKEND_ID,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { DEFAULT_LOCAL_BACKEND_ID } from "#/api/backend-registry/default-backend";
|
||||
import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage";
|
||||
import {
|
||||
@@ -28,12 +31,15 @@ function makeWrapper(queryClient = new QueryClient()) {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "session-key");
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllEnvs();
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
@@ -97,7 +103,9 @@ describe("ActiveBackendProvider", () => {
|
||||
expect(result.current.active.backend.id).toBe(added!.id);
|
||||
// Previous backends remain in the registry.
|
||||
expect(result.current.backends).toHaveLength(2);
|
||||
expect(result.current.backends.find((b) => b.id === DEFAULT_LOCAL_BACKEND_ID)).toBeDefined();
|
||||
expect(
|
||||
result.current.backends.find((b) => b.id === DEFAULT_LOCAL_BACKEND_ID),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("setActive switches the active backend without touching unrelated React Query cache entries", () => {
|
||||
@@ -161,7 +169,7 @@ describe("ActiveBackendProvider", () => {
|
||||
});
|
||||
|
||||
// @spec BM-003 — Fallback on active backend removal
|
||||
it("removeBackend allows removing the seeded default and falls back to a synthesized env-derived backend", () => {
|
||||
it("removeBackend allows removing the seeded default and falls back to no backend", () => {
|
||||
const { result } = renderHook(() => useActiveBackendContext(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
@@ -173,11 +181,7 @@ describe("ActiveBackendProvider", () => {
|
||||
});
|
||||
|
||||
expect(result.current.backends).toEqual([]);
|
||||
// No registered backend remains, so the active store synthesizes
|
||||
// an env-derived local backend (with the well-known default id) so
|
||||
// synchronous call sites never have to handle a null backend.
|
||||
expect(result.current.active.backend.id).toBe(DEFAULT_LOCAL_BACKEND_ID);
|
||||
expect(result.current.active.backend.kind).toBe("local");
|
||||
expect(result.current.active.backend.id).toBe(NO_BACKEND_ID);
|
||||
});
|
||||
|
||||
it("throws if used outside the provider", () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
NO_BACKEND_ID,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema";
|
||||
import type { SettingsSchema } from "#/types/settings";
|
||||
|
||||
const agentSchema: SettingsSchema = {
|
||||
model_name: "AgentSettings",
|
||||
sections: [
|
||||
{
|
||||
key: "llm",
|
||||
label: "LLM",
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function makeWrapper(queryClient = new QueryClient()) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>{children}</ActiveBackendProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
function useSchemaWithBackendContext() {
|
||||
return {
|
||||
backendContext: useActiveBackendContext(),
|
||||
schemaQuery: useAgentSettingsSchema(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.stubEnv("VITE_BACKEND_BASE_URL", "");
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "");
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("useAgentSettingsSchema", () => {
|
||||
it("waits for a configured backend, then fetches schema for that backend", async () => {
|
||||
const getSettingsSchemaSpy = vi
|
||||
.spyOn(SettingsService, "getSettingsSchema")
|
||||
.mockResolvedValue(agentSchema);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSchemaWithBackendContext(), {
|
||||
wrapper: makeWrapper(queryClient),
|
||||
});
|
||||
|
||||
expect(result.current.backendContext.active.backend.id).toBe(NO_BACKEND_ID);
|
||||
await waitFor(() => {
|
||||
expect(getSettingsSchemaSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.backendContext.addBackend({
|
||||
name: "Local",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "session-key",
|
||||
kind: "local",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.schemaQuery.data).toEqual(agentSchema);
|
||||
});
|
||||
expect(getSettingsSchemaSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ServerClient } from "@openhands/typescript-client/clients";
|
||||
import { SettingsClient } from "@openhands/typescript-client/clients";
|
||||
import React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
|
||||
|
||||
const getServerInfoMock = vi.fn();
|
||||
const getSettingsMock = vi.fn();
|
||||
const getCurrentCloudApiKeyMock = vi.fn();
|
||||
|
||||
vi.mock("@openhands/typescript-client/clients", () => ({
|
||||
ServerClient: vi.fn(function ServerClientMock() {
|
||||
return { getServerInfo: getServerInfoMock };
|
||||
SettingsClient: vi.fn(function SettingsClientMock() {
|
||||
return { getSettings: getSettingsMock };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -53,9 +53,9 @@ function wrapper({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getServerInfoMock.mockReset();
|
||||
getSettingsMock.mockReset();
|
||||
getCurrentCloudApiKeyMock.mockReset();
|
||||
vi.mocked(ServerClient).mockClear();
|
||||
vi.mocked(SettingsClient).mockClear();
|
||||
window.localStorage.clear();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
@@ -67,8 +67,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("useBackendsHealth", () => {
|
||||
it("probes local backends via getServerInfo and reports connected", async () => {
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.18.0" });
|
||||
it("probes local backends via authenticated settings and reports connected", async () => {
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
wrapper,
|
||||
@@ -77,12 +77,12 @@ describe("useBackendsHealth", () => {
|
||||
await waitFor(() =>
|
||||
expect(result.current[localBackend.id].isConnected).toBe(true),
|
||||
);
|
||||
expect(getServerInfoMock).toHaveBeenCalled();
|
||||
expect(getSettingsMock).toHaveBeenCalled();
|
||||
expect(getCurrentCloudApiKeyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports disconnected when the local probe throws", async () => {
|
||||
getServerInfoMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
getSettingsMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
wrapper,
|
||||
@@ -93,6 +93,26 @@ describe("useBackendsHealth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports invalid API key when the authenticated local probe returns 401", async () => {
|
||||
getSettingsMock.mockRejectedValue(
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
name: "HttpError",
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current[localBackend.id]).toMatchObject({
|
||||
isConnected: false,
|
||||
lastError: "Invalid API key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("probes cloud backends via getCurrentCloudApiKey", async () => {
|
||||
getCurrentCloudApiKeyMock.mockResolvedValue({
|
||||
orgId: "org-1",
|
||||
@@ -107,7 +127,7 @@ describe("useBackendsHealth", () => {
|
||||
expect(result.current[cloudBackend.id].isConnected).toBe(true),
|
||||
);
|
||||
expect(getCurrentCloudApiKeyMock).toHaveBeenCalledWith(cloudBackend);
|
||||
expect(getServerInfoMock).not.toHaveBeenCalled();
|
||||
expect(getSettingsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports disconnected when the cloud probe throws", async () => {
|
||||
@@ -124,10 +144,10 @@ describe("useBackendsHealth", () => {
|
||||
|
||||
it("reports null while the first probe is still in flight", async () => {
|
||||
let resolveProbe!: () => void;
|
||||
getServerInfoMock.mockImplementation(
|
||||
getSettingsMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<unknown>((resolve) => {
|
||||
resolveProbe = () => resolve({ version: "1.18.0" });
|
||||
resolveProbe = () => resolve({});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -145,7 +165,7 @@ describe("useBackendsHealth", () => {
|
||||
|
||||
it("records the failure count and last error to the health store after a failed probe", async () => {
|
||||
// Arrange
|
||||
getServerInfoMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
getSettingsMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
@@ -187,7 +207,7 @@ describe("useBackendsHealth", () => {
|
||||
}),
|
||||
);
|
||||
__resetHealthStoreForTests();
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.18.0" });
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
@@ -199,7 +219,7 @@ describe("useBackendsHealth", () => {
|
||||
});
|
||||
|
||||
// Assert — polling is gated off; no probe goes out.
|
||||
expect(getServerInfoMock).not.toHaveBeenCalled();
|
||||
expect(getSettingsMock).not.toHaveBeenCalled();
|
||||
expect(result.current[localBackend.id]).toMatchObject({
|
||||
isConnected: false,
|
||||
disabled: true,
|
||||
@@ -220,12 +240,12 @@ describe("useBackendsHealth", () => {
|
||||
}),
|
||||
);
|
||||
__resetHealthStoreForTests();
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.18.0" });
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useBackendsHealth([localBackend]), {
|
||||
wrapper,
|
||||
});
|
||||
expect(getServerInfoMock).not.toHaveBeenCalled();
|
||||
expect(getSettingsMock).not.toHaveBeenCalled();
|
||||
|
||||
// Act — the active-backend-context calls resetBackendHealth when
|
||||
// host or apiKey changes; do that directly so we don't have to
|
||||
@@ -238,7 +258,7 @@ describe("useBackendsHealth", () => {
|
||||
await waitFor(() =>
|
||||
expect(result.current[localBackend.id].isConnected).toBe(true),
|
||||
);
|
||||
expect(getServerInfoMock).toHaveBeenCalled();
|
||||
expect(getSettingsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-probes a persisted-disabled backend when explicitly asked and clears the stale health entry on success", async () => {
|
||||
@@ -254,7 +274,7 @@ describe("useBackendsHealth", () => {
|
||||
}),
|
||||
);
|
||||
__resetHealthStoreForTests();
|
||||
getServerInfoMock.mockResolvedValue({ version: "1.18.0" });
|
||||
getSettingsMock.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useBackendsHealth([localBackend], { probeDisabledOnce: true }),
|
||||
@@ -269,7 +289,7 @@ describe("useBackendsHealth", () => {
|
||||
disabled: false,
|
||||
}),
|
||||
);
|
||||
expect(getServerInfoMock).toHaveBeenCalled();
|
||||
expect(getSettingsMock).toHaveBeenCalled();
|
||||
expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildAutomationCommand,
|
||||
buildConfig,
|
||||
buildRouteArgs,
|
||||
buildViteBackendEnv,
|
||||
getFrontendBackend,
|
||||
getLocalServiceRoutes,
|
||||
DEFAULT_AUTOMATION_REPO,
|
||||
@@ -373,6 +374,69 @@ describe("stack mode routing", () => {
|
||||
expect(buildRouteArgs(getLocalServiceRoutes(config))).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not bake a host workspace path in frontend-only mode by default", async () => {
|
||||
const config = await buildConfig(
|
||||
{ frontendOnly: true },
|
||||
envWithIsolatedKeyPath(),
|
||||
);
|
||||
|
||||
expect(config.viteWorkingDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors explicit frontend-only VITE_WORKING_DIR values", async () => {
|
||||
const config = await buildConfig(
|
||||
{ frontendOnly: true },
|
||||
envWithIsolatedKeyPath({ VITE_WORKING_DIR: "workspace/project" }),
|
||||
);
|
||||
|
||||
expect(config.viteWorkingDir).toBe("workspace/project");
|
||||
});
|
||||
|
||||
it("bakes the host workspace path when this launcher starts the agent-server", async () => {
|
||||
const config = await buildConfig({}, envWithIsolatedKeyPath());
|
||||
|
||||
expect(config.viteWorkingDir).toBe(
|
||||
path.join(config.stateDir, "workspaces"),
|
||||
);
|
||||
});
|
||||
|
||||
it("points frontend-only Vite at a separately running backend by default", async () => {
|
||||
const config = await buildConfig(
|
||||
{ frontendOnly: true },
|
||||
envWithIsolatedKeyPath(),
|
||||
);
|
||||
|
||||
expect(buildViteBackendEnv(config, {})).toEqual({
|
||||
VITE_BACKEND_HOST: "127.0.0.1:8000",
|
||||
VITE_BACKEND_BASE_URL: "http://127.0.0.1:8000",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps full-stack Vite pointed at this launcher's ingress", async () => {
|
||||
const config = await buildConfig({}, envWithIsolatedKeyPath());
|
||||
|
||||
expect(buildViteBackendEnv(config, {})).toEqual({
|
||||
VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`,
|
||||
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows frontend-only Vite to target an explicit backend URL", async () => {
|
||||
const config = await buildConfig(
|
||||
{ frontendOnly: true },
|
||||
envWithIsolatedKeyPath(),
|
||||
);
|
||||
|
||||
expect(
|
||||
buildViteBackendEnv(config, {
|
||||
VITE_BACKEND_BASE_URL: "https://backend.example.test",
|
||||
}),
|
||||
).toEqual({
|
||||
VITE_BACKEND_HOST: "backend.example.test",
|
||||
VITE_BACKEND_BASE_URL: "https://backend.example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes only agent-server and automation in backend-only mode", async () => {
|
||||
const config = await buildConfig(
|
||||
{ backendOnly: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { connect as netConnect, type Socket } from "node:net";
|
||||
import { connect as netConnect, type AddressInfo, type Socket } from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
@@ -14,6 +14,109 @@ const repoRoot = path.resolve(
|
||||
);
|
||||
|
||||
const ingressScript = path.join(repoRoot, "scripts", "ingress.mjs");
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
function originForPort(port: number) {
|
||||
return `http://${loopbackHost}:${port}`;
|
||||
}
|
||||
|
||||
function serverPort(server: Server) {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected server to be listening on a TCP port");
|
||||
}
|
||||
return (address as AddressInfo).port;
|
||||
}
|
||||
|
||||
async function listenOnLoopback(server: Server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off("error", onError);
|
||||
reject(error);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(0, loopbackHost, () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return serverPort(server);
|
||||
}
|
||||
|
||||
async function closeServer(server?: Server) {
|
||||
if (!server?.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = createServer();
|
||||
try {
|
||||
return await listenOnLoopback(server);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function canConnect(port: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = netConnect({ host: loopbackHost, port });
|
||||
let settled = false;
|
||||
const finish = (connected: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(connected);
|
||||
};
|
||||
socket.setTimeout(500);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", () => finish(false));
|
||||
socket.once("timeout", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPort(port: number, child?: ChildProcess) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (child && child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Process exited before port ${port} was ready: ${child.exitCode}`,
|
||||
);
|
||||
}
|
||||
if (await canConnect(port)) {
|
||||
return;
|
||||
}
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`Timed out waiting for port ${port}`);
|
||||
}
|
||||
|
||||
async function stopChild(child?: ChildProcess) {
|
||||
if (!child || child.exitCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const exited = once(child, "exit");
|
||||
const result = await Promise.race([
|
||||
exited.then(() => "exit" as const),
|
||||
delay(2000).then(() => "timeout" as const),
|
||||
]);
|
||||
if (result === "timeout" && child.exitCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
await Promise.race([exited, delay(1000)]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ingress.mjs CLI", () => {
|
||||
it("shows help with --help flag", async () => {
|
||||
@@ -54,9 +157,16 @@ describe("ingress.mjs CLI", () => {
|
||||
});
|
||||
|
||||
it("parses --port argument correctly", async () => {
|
||||
const port = await getFreePort();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[ingressScript, "--port", "9999", "--default", "http://localhost:3000"],
|
||||
[
|
||||
ingressScript,
|
||||
"--port",
|
||||
port.toString(),
|
||||
"--default",
|
||||
"http://localhost:3000",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -68,20 +178,20 @@ describe("ingress.mjs CLI", () => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
// Wait for startup message
|
||||
await delay(500);
|
||||
child.kill("SIGTERM");
|
||||
await waitForPort(port, child);
|
||||
await stopChild(child);
|
||||
|
||||
expect(output).toContain("9999");
|
||||
expect(output).toContain(port.toString());
|
||||
});
|
||||
|
||||
it("parses --route arguments correctly", async () => {
|
||||
const port = await getFreePort();
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
ingressScript,
|
||||
"--port",
|
||||
"9998",
|
||||
port.toString(),
|
||||
"--route",
|
||||
"/api=http://localhost:8000",
|
||||
"--route",
|
||||
@@ -98,8 +208,8 @@ describe("ingress.mjs CLI", () => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
await delay(500);
|
||||
child.kill("SIGTERM");
|
||||
await waitForPort(port, child);
|
||||
await stopChild(child);
|
||||
|
||||
expect(output).toContain("/api");
|
||||
expect(output).toContain("http://localhost:8000");
|
||||
@@ -112,10 +222,9 @@ describe("ingress proxy functionality", () => {
|
||||
let backend1: Server;
|
||||
let backend2: Server;
|
||||
let ingressProcess: ChildProcess;
|
||||
// Use ports in 29000 range to avoid conflicts with VS Code server (19000)
|
||||
const backend1Port = 29001;
|
||||
const backend2Port = 29002;
|
||||
const ingressPort = 29000;
|
||||
let backend1Port: number;
|
||||
let backend2Port: number;
|
||||
let ingressPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create mock backend 1
|
||||
@@ -123,16 +232,17 @@ describe("ingress proxy functionality", () => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ backend: 1, path: req.url }));
|
||||
});
|
||||
await new Promise<void>((resolve) => backend1.listen(backend1Port, resolve));
|
||||
backend1Port = await listenOnLoopback(backend1);
|
||||
|
||||
// Create mock backend 2
|
||||
backend2 = createServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ backend: 2, path: req.url }));
|
||||
});
|
||||
await new Promise<void>((resolve) => backend2.listen(backend2Port, resolve));
|
||||
backend2Port = await listenOnLoopback(backend2);
|
||||
|
||||
// Start ingress
|
||||
ingressPort = await getFreePort();
|
||||
ingressProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -140,11 +250,11 @@ describe("ingress proxy functionality", () => {
|
||||
"--port",
|
||||
ingressPort.toString(),
|
||||
"--route",
|
||||
`/api/v2=http://localhost:${backend2Port}`,
|
||||
`/api/v2=${originForPort(backend2Port)}`,
|
||||
"--route",
|
||||
`/api=http://localhost:${backend1Port}`,
|
||||
`/api=${originForPort(backend1Port)}`,
|
||||
"--default",
|
||||
`http://localhost:${backend1Port}`,
|
||||
originForPort(backend1Port),
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
@@ -152,18 +262,17 @@ describe("ingress proxy functionality", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for ingress to start
|
||||
await delay(1000);
|
||||
await waitForPort(ingressPort, ingressProcess);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ingressProcess?.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => backend1?.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => backend2?.close(() => resolve()));
|
||||
await stopChild(ingressProcess);
|
||||
await closeServer(backend1);
|
||||
await closeServer(backend2);
|
||||
});
|
||||
|
||||
it("routes /api requests to backend1", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/api/test`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/api/test`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -172,7 +281,7 @@ describe("ingress proxy functionality", () => {
|
||||
});
|
||||
|
||||
it("routes /api/v2 requests to backend2 (more specific route)", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/api/v2/test`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/api/v2/test`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -181,7 +290,7 @@ describe("ingress proxy functionality", () => {
|
||||
});
|
||||
|
||||
it("routes unmatched paths to default backend", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/other/path`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/other/path`);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -191,7 +300,7 @@ describe("ingress proxy functionality", () => {
|
||||
|
||||
it("preserves query parameters", async () => {
|
||||
const response = await fetch(
|
||||
`http://localhost:${ingressPort}/api/test?foo=bar&baz=123`,
|
||||
`${originForPort(ingressPort)}/api/test?foo=bar&baz=123`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -201,7 +310,8 @@ describe("ingress proxy functionality", () => {
|
||||
|
||||
it("returns 502 when backend is unavailable", async () => {
|
||||
// Start a fresh ingress pointing to a non-existent backend
|
||||
const badIngressPort = 29003;
|
||||
const badBackendPort = await getFreePort();
|
||||
const badIngressPort = await getFreePort();
|
||||
const badIngress = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -209,7 +319,7 @@ describe("ingress proxy functionality", () => {
|
||||
"--port",
|
||||
badIngressPort.toString(),
|
||||
"--default",
|
||||
"http://localhost:59999", // Non-existent port
|
||||
originForPort(badBackendPort),
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
@@ -217,15 +327,15 @@ describe("ingress proxy functionality", () => {
|
||||
},
|
||||
);
|
||||
|
||||
await delay(500);
|
||||
await waitForPort(badIngressPort, badIngress);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${badIngressPort}/test`);
|
||||
const response = await fetch(`${originForPort(badIngressPort)}/test`);
|
||||
expect(response.status).toBe(502);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("Bad Gateway");
|
||||
} finally {
|
||||
badIngress.kill("SIGTERM");
|
||||
await stopChild(badIngress);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -233,16 +343,17 @@ describe("ingress proxy functionality", () => {
|
||||
describe("ingress route matching", () => {
|
||||
let backend: Server;
|
||||
let ingressProcess: ChildProcess;
|
||||
const backendPort = 19011;
|
||||
const ingressPort = 19010;
|
||||
let backendPort: number;
|
||||
let ingressPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
backend = createServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end(req.url);
|
||||
});
|
||||
await new Promise<void>((resolve) => backend.listen(backendPort, resolve));
|
||||
backendPort = await listenOnLoopback(backend);
|
||||
|
||||
ingressPort = await getFreePort();
|
||||
ingressProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -250,11 +361,11 @@ describe("ingress route matching", () => {
|
||||
"--port",
|
||||
ingressPort.toString(),
|
||||
"--route",
|
||||
`/api/automation=http://localhost:${backendPort}`,
|
||||
`/api/automation=${originForPort(backendPort)}`,
|
||||
"--route",
|
||||
`/api=http://localhost:${backendPort}`,
|
||||
`/api=${originForPort(backendPort)}`,
|
||||
"--route",
|
||||
`/sockets=http://localhost:${backendPort}`,
|
||||
`/sockets=${originForPort(backendPort)}`,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
@@ -262,21 +373,21 @@ describe("ingress route matching", () => {
|
||||
},
|
||||
);
|
||||
|
||||
await delay(1000);
|
||||
await waitForPort(ingressPort, ingressProcess);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ingressProcess?.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => backend?.close(() => resolve()));
|
||||
await stopChild(ingressProcess);
|
||||
await closeServer(backend);
|
||||
});
|
||||
|
||||
it("matches exact path", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/api`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/api`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("matches path with trailing content", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/api/users`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/api/users`);
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toBe("/api/users");
|
||||
@@ -284,7 +395,7 @@ describe("ingress route matching", () => {
|
||||
|
||||
it("matches longer prefix before shorter", async () => {
|
||||
const response = await fetch(
|
||||
`http://localhost:${ingressPort}/api/automation/docs`,
|
||||
`${originForPort(ingressPort)}/api/automation/docs`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
@@ -292,16 +403,14 @@ describe("ingress route matching", () => {
|
||||
});
|
||||
|
||||
it("matches path with query string", async () => {
|
||||
const response = await fetch(
|
||||
`http://localhost:${ingressPort}/api?foo=bar`,
|
||||
);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/api?foo=bar`);
|
||||
expect(response.status).toBe(200);
|
||||
const text = await response.text();
|
||||
expect(text).toBe("/api?foo=bar");
|
||||
});
|
||||
|
||||
it("returns 503 for unmatched routes with no default", async () => {
|
||||
const response = await fetch(`http://localhost:${ingressPort}/unknown`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/unknown`);
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -315,8 +424,8 @@ describe("ingress socket-error resilience", () => {
|
||||
let upstreamSockets: Duplex[];
|
||||
let ingressProcess: ChildProcess;
|
||||
let ingressStderr: string;
|
||||
const upstreamPort = 29111;
|
||||
const ingressPort = 29110;
|
||||
let upstreamPort: number;
|
||||
let ingressPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstreamSockets = [];
|
||||
@@ -349,8 +458,9 @@ describe("ingress socket-error resilience", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => upstream.listen(upstreamPort, resolve));
|
||||
upstreamPort = await listenOnLoopback(upstream);
|
||||
|
||||
ingressPort = await getFreePort();
|
||||
ingressStderr = "";
|
||||
ingressProcess = spawn(
|
||||
process.execPath,
|
||||
@@ -359,7 +469,7 @@ describe("ingress socket-error resilience", () => {
|
||||
"--port",
|
||||
ingressPort.toString(),
|
||||
"--default",
|
||||
`http://localhost:${upstreamPort}`,
|
||||
originForPort(upstreamPort),
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
@@ -370,11 +480,11 @@ describe("ingress socket-error resilience", () => {
|
||||
ingressStderr += chunk.toString();
|
||||
});
|
||||
|
||||
await delay(800);
|
||||
await waitForPort(ingressPort, ingressProcess);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ingressProcess?.kill("SIGTERM");
|
||||
await stopChild(ingressProcess);
|
||||
for (const s of upstreamSockets) {
|
||||
try {
|
||||
s.destroy();
|
||||
@@ -382,7 +492,7 @@ describe("ingress socket-error resilience", () => {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolve) => upstream?.close(() => resolve()));
|
||||
await closeServer(upstream);
|
||||
});
|
||||
|
||||
function openWebSocketHandshake(port: number): Promise<Socket> {
|
||||
@@ -420,7 +530,7 @@ describe("ingress socket-error resilience", () => {
|
||||
expect(ingressProcess.signalCode).toBeNull();
|
||||
|
||||
// And it must still be serving HTTP traffic.
|
||||
const response = await fetch(`http://localhost:${ingressPort}/health`);
|
||||
const response = await fetch(`${originForPort(ingressPort)}/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// The unhandled-error crash signature must not appear in stderr.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
OPENHANDS_LLM_PROXY_BASE_URL,
|
||||
isOpenHandsProviderModel,
|
||||
} from "#/utils/openhands-llm";
|
||||
|
||||
describe("openhands LLM helpers", () => {
|
||||
it("identifies OpenHands provider model ids", () => {
|
||||
expect(isOpenHandsProviderModel("openhands/gpt-5.5")).toBe(true);
|
||||
expect(isOpenHandsProviderModel("litellm_proxy/gpt-5.5")).toBe(false);
|
||||
expect(isOpenHandsProviderModel("openai/gpt-4o")).toBe(false);
|
||||
expect(isOpenHandsProviderModel(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("exports the All-Hands LiteLLM proxy base URL", () => {
|
||||
expect(OPENHANDS_LLM_PROXY_BASE_URL).toBe(
|
||||
"https://llm-proxy.app.all-hands.dev/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -394,6 +394,13 @@ async function buildConfig(args, env = process.env) {
|
||||
|
||||
// Data directories (same as dev-safe.mjs)
|
||||
stateDir,
|
||||
// Only bake the host-side workspace path when this launcher also starts
|
||||
// the agent-server that can read it. In frontend-only mode the backend may
|
||||
// be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
|
||||
// user explicitly supplied a backend-relative value.
|
||||
viteWorkingDir: launchAgentServer
|
||||
? safeConfig.workingDir
|
||||
: env.VITE_WORKING_DIR,
|
||||
|
||||
// Auth — single key for both backends
|
||||
sessionApiKey,
|
||||
@@ -642,6 +649,20 @@ function getFrontendBackend(config) {
|
||||
return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
|
||||
}
|
||||
|
||||
function buildViteBackendEnv(config, env = process.env) {
|
||||
const backendBaseUrl = config.launchAgentServer
|
||||
? `http://127.0.0.1:${config.ingressPort}`
|
||||
: (env.VITE_BACKEND_BASE_URL ?? "http://127.0.0.1:8000");
|
||||
const backendHost = config.launchAgentServer
|
||||
? `127.0.0.1:${config.ingressPort}`
|
||||
: (env.VITE_BACKEND_HOST ?? new URL(backendBaseUrl).host);
|
||||
|
||||
return {
|
||||
VITE_BACKEND_HOST: backendHost,
|
||||
VITE_BACKEND_BASE_URL: backendBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgentServerAutomationEnv(config) {
|
||||
return {
|
||||
// Make the session API key available to terminal commands spawned by the
|
||||
@@ -874,13 +895,14 @@ function startVite(config) {
|
||||
: null;
|
||||
|
||||
const viteEnv = {
|
||||
// Point Vite at the ingress (so client-side fetches work)
|
||||
VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`,
|
||||
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
|
||||
VITE_WORKING_DIR:
|
||||
config.viteWorkingDir ?? join(config.stateDir, "workspaces"),
|
||||
// Full-stack mode points Vite at this launcher's ingress. Frontend-only
|
||||
// mode uses the separately running backend ingress instead.
|
||||
...buildViteBackendEnv(config),
|
||||
VITE_FRONTEND_PORT: config.vitePort.toString(),
|
||||
};
|
||||
if (config.viteWorkingDir) {
|
||||
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
|
||||
}
|
||||
|
||||
if (runtimeServicesInfo) {
|
||||
// Inform the frontend (and downstream, the agent's system prompt) about
|
||||
@@ -1290,6 +1312,7 @@ export {
|
||||
buildAutomationCommand,
|
||||
buildConfig,
|
||||
buildRouteArgs,
|
||||
buildViteBackendEnv,
|
||||
getFrontendBackend,
|
||||
getLocalServiceRoutes,
|
||||
main,
|
||||
|
||||
+20
-19
@@ -40,28 +40,29 @@ export function buildFrontend(config, args = {}) {
|
||||
);
|
||||
|
||||
const cmd = buildNpmScriptCommand("build:app");
|
||||
const buildEnv = {
|
||||
...process.env,
|
||||
// Bake the session API key — used by the frontend for both agent-server
|
||||
// and automation auth via the `X-Session-API-Key` header.
|
||||
VITE_SESSION_API_KEY: config.sessionApiKey,
|
||||
// Bake a description of the runtime services in this dev stack so the
|
||||
// frontend can populate the agent's <RUNTIME_SERVICES> system-prompt
|
||||
// block when creating a conversation.
|
||||
VITE_RUNTIME_SERVICES_INFO: JSON.stringify(
|
||||
buildAutomationRuntimeServicesInfo(config),
|
||||
),
|
||||
// Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes
|
||||
// the runtime fall back to window.location.origin, which keeps the build
|
||||
// portable across localhost, LAN hosts, and tunnels such as ngrok.
|
||||
};
|
||||
if (config.viteWorkingDir) {
|
||||
buildEnv.VITE_WORKING_DIR = config.viteWorkingDir;
|
||||
}
|
||||
|
||||
const result = spawnSync(cmd.command, cmd.args, {
|
||||
cwd: config.canvasPath,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
// Bake the same default workspace path that the dynamic launcher passes
|
||||
// to Vite.
|
||||
VITE_WORKING_DIR:
|
||||
config.viteWorkingDir ?? join(config.stateDir, "workspaces"),
|
||||
// Bake the session API key — used by the frontend for both agent-server
|
||||
// and automation auth via the `X-Session-API-Key` header.
|
||||
VITE_SESSION_API_KEY: config.sessionApiKey,
|
||||
// Bake a description of the runtime services in this dev stack so the
|
||||
// frontend can populate the agent's <RUNTIME_SERVICES> system-prompt
|
||||
// block when creating a conversation.
|
||||
VITE_RUNTIME_SERVICES_INFO: JSON.stringify(
|
||||
buildAutomationRuntimeServicesInfo(config),
|
||||
),
|
||||
// Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes
|
||||
// the runtime fall back to window.location.origin, which keeps the build
|
||||
// portable across localhost, LAN hosts, and tunnels such as ngrok.
|
||||
},
|
||||
env: buildEnv,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
|
||||
@@ -800,7 +800,7 @@ export function buildStartConversationRequest(
|
||||
|
||||
if (options.customSecrets && options.customSecrets.length > 0) {
|
||||
const backend = getEffectiveLocalBackend();
|
||||
const headers = buildAuthHeaders(backend);
|
||||
const headers = backend ? buildAuthHeaders(backend) : {};
|
||||
|
||||
const secrets: Record<string, LookupSecret> = {};
|
||||
for (const secret of options.customSecrets) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import {
|
||||
getAgentServerSessionApiKey,
|
||||
getAgentServerWorkingDir,
|
||||
} from "./agent-server-config";
|
||||
import { getAgentServerWorkingDir } from "./agent-server-config";
|
||||
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
||||
import { DEFAULT_LOCAL_BACKEND_ID } from "./backend-registry/default-backend";
|
||||
import type { Backend } from "./backend-registry/types";
|
||||
|
||||
export interface AgentServerClientOverrides {
|
||||
@@ -23,33 +19,46 @@ export interface AgentServerClientOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class NoBackendAvailableError extends Error {
|
||||
constructor() {
|
||||
super("No backend is configured.");
|
||||
this.name = "NoBackendAvailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export const isNoBackendAvailableError = (
|
||||
error: unknown,
|
||||
): error is NoBackendAvailableError =>
|
||||
error instanceof NoBackendAvailableError ||
|
||||
(typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
error.name === "NoBackendAvailableError");
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveHost(
|
||||
overrides: AgentServerClientOverrides,
|
||||
backend: Backend,
|
||||
backend: Backend | null,
|
||||
): string {
|
||||
if (overrides.host) return normalizeHost(overrides.host);
|
||||
if (overrides.conversationUrl)
|
||||
return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl));
|
||||
return normalizeHost(backend.host);
|
||||
return normalizeHost(backend?.host ?? "");
|
||||
}
|
||||
|
||||
export function getAgentServerClientOptions(
|
||||
overrides: AgentServerClientOverrides = {},
|
||||
): AgentServerClientOptions {
|
||||
const backend = getEffectiveLocalBackend();
|
||||
const configuredSessionApiKey = getAgentServerSessionApiKey();
|
||||
const defaultLocalApiKeyOverride =
|
||||
backend.id === DEFAULT_LOCAL_BACKEND_ID ? configuredSessionApiKey : null;
|
||||
if (!backend && !overrides.host && !overrides.conversationUrl) {
|
||||
throw new NoBackendAvailableError();
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
overrides.sessionApiKey ??
|
||||
overrides.apiKey ??
|
||||
defaultLocalApiKeyOverride ??
|
||||
backend.apiKey ??
|
||||
undefined;
|
||||
overrides.sessionApiKey ?? overrides.apiKey ?? backend?.apiKey ?? undefined;
|
||||
|
||||
return {
|
||||
host: resolveHost(overrides, backend),
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client";
|
||||
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
||||
import { isAuthRequired } from "#/api/agent-server-config";
|
||||
import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
getActiveBackend,
|
||||
getEffectiveLocalBackend,
|
||||
isNoBackend,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
|
||||
const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -92,6 +96,20 @@ export async function loadAgentServerInfo() {
|
||||
// backend when that backend is cloud, because cloud hosts don't
|
||||
// expose /api/server_info and would fail with a CORS error besides.
|
||||
const local = getEffectiveLocalBackend();
|
||||
if (!local) {
|
||||
clearCachedAgentServerInfo();
|
||||
|
||||
// Empty registry (NO_BACKEND sentinel) — the user has no backend
|
||||
// configured at all. Throw so root.tsx shows the manage-backends
|
||||
// modal instead of silently rendering a broken home page.
|
||||
if (isNoBackend(getActiveBackend().backend)) {
|
||||
throw new AgentServerUnavailableError("No backend configured");
|
||||
}
|
||||
|
||||
// Active backend is cloud — no local probe needed.
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientOptions = getAgentServerClientOptions({
|
||||
host: local.host,
|
||||
sessionApiKey: local.apiKey || null,
|
||||
|
||||
@@ -1,55 +1,10 @@
|
||||
export const AGENT_SERVER_CONFIG_STORAGE_KEY = "openhands-agent-server-config";
|
||||
export const DEFAULT_WORKING_DIR = "workspace/project";
|
||||
|
||||
interface StoredAgentServerConfig {
|
||||
baseUrl?: string | null;
|
||||
sessionApiKey?: string | null;
|
||||
workingDir?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentServerFormDefaults {
|
||||
baseUrl: string;
|
||||
sessionApiKey: string;
|
||||
}
|
||||
|
||||
function readStoredConfig(): StoredAgentServerConfig {
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AGENT_SERVER_CONFIG_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as StoredAgentServerConfig;
|
||||
return parsed ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredConfig(config: StoredAgentServerConfig): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const nextConfig = Object.fromEntries(
|
||||
Object.entries(config).flatMap(([key, value]) => {
|
||||
if (typeof value !== "string") return [];
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
return [[key, trimmed]];
|
||||
}),
|
||||
) as StoredAgentServerConfig;
|
||||
|
||||
if (Object.keys(nextConfig).length === 0) {
|
||||
window.localStorage.removeItem(AGENT_SERVER_CONFIG_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
JSON.stringify(nextConfig),
|
||||
);
|
||||
}
|
||||
|
||||
function trimToNull(value?: string | null): string | null {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
@@ -72,130 +27,39 @@ function normalizeBaseUrl(value?: string | null): string | null {
|
||||
}
|
||||
|
||||
function getConfiguredBaseUrl(): string | null {
|
||||
const storedUrl = normalizeBaseUrl(readStoredConfig().baseUrl);
|
||||
if (storedUrl) return storedUrl;
|
||||
|
||||
return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the baked-in session API key from the Vite env or the runtime
|
||||
* injection by static-server.mjs. This represents the *server's* truth
|
||||
* and is only set in non-public (local) mode.
|
||||
*/
|
||||
export function getBakedSessionApiKey(): string | null {
|
||||
return trimToNull(import.meta.env.VITE_SESSION_API_KEY);
|
||||
}
|
||||
|
||||
function getConfiguredSessionApiKey(): string | null {
|
||||
const storedKey = trimToNull(readStoredConfig().sessionApiKey);
|
||||
if (storedKey) return storedKey;
|
||||
|
||||
return getBakedSessionApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the baked-in session API key into `openhands-agent-server-config`
|
||||
* localStorage when the stored value has drifted.
|
||||
*
|
||||
* In non-public (local) mode the dev scripts bake the session key into
|
||||
* `VITE_SESSION_API_KEY` (Vite dev) or inject it via `static-server.mjs`
|
||||
* (`--session-api-key`). That key represents the *server's* truth — the
|
||||
* agent-server was started with the same value as `OH_SESSION_API_KEYS_0`.
|
||||
*
|
||||
* If a user restarts the stack with a different `LOCAL_BACKEND_API_KEY`,
|
||||
* the baked-in key changes but the old value may still be persisted in
|
||||
* localStorage (written by the onboarding form, the Settings page, or a
|
||||
* previous key injection). Without this sync the stale stored key would
|
||||
* shadow the new baked key everywhere (`getConfiguredSessionApiKey()`
|
||||
* reads localStorage first), causing 401s.
|
||||
*
|
||||
* Must run **before** any call to `getConfiguredSessionApiKey()` or
|
||||
* `makeDefaultLocalBackend()` — called from `readStoredBackends()` in
|
||||
* `storage.ts` which is evaluated at module init time.
|
||||
*/
|
||||
export function syncBakedSessionApiKey(): void {
|
||||
const bakedKey = getBakedSessionApiKey();
|
||||
if (!bakedKey) return; // public mode or no key baked in
|
||||
|
||||
const storedConfig = readStoredConfig();
|
||||
const storedKey = trimToNull(storedConfig.sessionApiKey);
|
||||
if (storedKey && storedKey !== bakedKey) {
|
||||
writeStoredConfig({ ...storedConfig, sessionApiKey: bakedKey });
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseProxyOrigin(baseUrl: string): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const configuredUrl = new URL(baseUrl);
|
||||
const localHosts = new Set(["127.0.0.1", "localhost", "0.0.0.0"]);
|
||||
const browserHostname = window.location.hostname;
|
||||
|
||||
return (
|
||||
localHosts.has(configuredUrl.hostname) &&
|
||||
(!localHosts.has(browserHostname) ||
|
||||
configuredUrl.hostname !== browserHostname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAgentServerBaseUrl(baseUrl: string | null): string | null {
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shouldUseProxyOrigin(baseUrl)) {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
export function getAgentServerFormDefaults(): AgentServerFormDefaults {
|
||||
return {
|
||||
baseUrl: getConfiguredBaseUrl() ?? "",
|
||||
sessionApiKey: getConfiguredSessionApiKey() ?? "",
|
||||
baseUrl: getAgentServerBaseUrl() ?? "",
|
||||
sessionApiKey: getAgentServerSessionApiKey() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function saveAgentServerConfig(config: AgentServerFormDefaults): void {
|
||||
const currentConfig = readStoredConfig();
|
||||
|
||||
writeStoredConfig({
|
||||
...currentConfig,
|
||||
baseUrl: normalizeBaseUrl(config.baseUrl),
|
||||
sessionApiKey: trimToNull(config.sessionApiKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentServerBaseUrl(): string {
|
||||
const configuredUrl = resolveAgentServerBaseUrl(getConfiguredBaseUrl());
|
||||
export function getAgentServerBaseUrl(): string | null {
|
||||
const configuredUrl = getConfiguredBaseUrl();
|
||||
if (configuredUrl) return configuredUrl;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
return "http://127.0.0.1:8000";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAgentServerSessionApiKey(): string | null {
|
||||
return getConfiguredSessionApiKey();
|
||||
return getBakedSessionApiKey();
|
||||
}
|
||||
|
||||
export function getAgentServerWorkingDir(): string {
|
||||
const envDir = import.meta.env.VITE_WORKING_DIR?.trim();
|
||||
if (envDir) return envDir;
|
||||
|
||||
const storedDir = readStoredConfig().workingDir?.trim();
|
||||
if (storedDir) return storedDir;
|
||||
|
||||
return DEFAULT_WORKING_DIR;
|
||||
}
|
||||
|
||||
@@ -220,24 +84,10 @@ export function getAgentServerHeaders(): Record<string, string> {
|
||||
return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether public skills from the OpenHands extensions marketplace
|
||||
* (https://github.com/OpenHands/extensions) should be loaded.
|
||||
*
|
||||
* Defaults to true. Set VITE_LOAD_PUBLIC_SKILLS=false to disable.
|
||||
*/
|
||||
export function shouldLoadPublicSkills(): boolean {
|
||||
return import.meta.env.VITE_LOAD_PUBLIC_SKILLS !== "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the deployment requires an API key from the user (public mode).
|
||||
*
|
||||
* Checks both the Vite build-time env var (`VITE_AUTH_REQUIRED`) and the
|
||||
* runtime flag injected by static-server.mjs (`window.__AGENT_CANVAS_AUTH_REQUIRED__`).
|
||||
* The runtime flag is needed for pre-built static binaries where
|
||||
* `VITE_AUTH_REQUIRED` was not set at build time.
|
||||
*/
|
||||
export function isAuthRequired(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_AUTH_REQUIRED === "true" ||
|
||||
@@ -247,15 +97,7 @@ export function isAuthRequired(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the server was started in public mode and the user
|
||||
* has not yet pasted an API key (nothing in localStorage, nothing baked
|
||||
* in via `VITE_SESSION_API_KEY`).
|
||||
*
|
||||
* Used by `root.tsx` to gate the app behind {@link ApiKeyEntryScreen}
|
||||
* before any network request is attempted.
|
||||
*/
|
||||
export function isAuthRequiredAndMissing(): boolean {
|
||||
if (!isAuthRequired()) return false;
|
||||
return !getConfiguredSessionApiKey();
|
||||
return !getAgentServerSessionApiKey();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getActiveBackend,
|
||||
getEffectiveLocalBackend,
|
||||
} from "../backend-registry/active-store";
|
||||
import { NoBackendAvailableError } from "../agent-server-client-options";
|
||||
import { callCloudProxy } from "../cloud/proxy";
|
||||
|
||||
const AUTOMATION_BASE_PATH = "/api/automation";
|
||||
@@ -34,6 +35,7 @@ localAutomationAxios.interceptors.request.use((config) => {
|
||||
// session key that scripts/static-server.mjs seeds into localStorage, fixing
|
||||
// the 401 errors reported in issue #829.
|
||||
const backend = getEffectiveLocalBackend();
|
||||
if (!backend) throw new NoBackendAvailableError();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (!config.baseURL) config.baseURL = backend.host;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { makeDefaultLocalBackend } from "./default-backend";
|
||||
import {
|
||||
readStoredActiveBackend,
|
||||
readStoredBackends,
|
||||
@@ -15,17 +14,27 @@ interface Snapshot {
|
||||
active: ResolvedActiveBackend;
|
||||
}
|
||||
|
||||
export const NO_BACKEND_ID = "no-backend";
|
||||
|
||||
/**
|
||||
* Pick the local backend the GUI should talk to for local-protocol calls
|
||||
* (settings, conversations, secrets, …). Prefers the user's first
|
||||
* registered local backend. As a last resort — when the registry has no
|
||||
* local entry at all — synthesize one from env/agent-server-config so
|
||||
* synchronous call sites never have to handle a `null` backend; the
|
||||
* synthesized entry is never persisted.
|
||||
* Sentinel returned when the registry has no usable backend. It must never be
|
||||
* persisted, and callers must check `isNoBackend()` before interpreting fields
|
||||
* like `kind`, `host`, or `apiKey`.
|
||||
*/
|
||||
function pickLocalBackend(backends: Backend[]): Backend {
|
||||
const firstLocal = backends.find((b) => b.kind === "local");
|
||||
return firstLocal ?? makeDefaultLocalBackend();
|
||||
export const NO_BACKEND: Backend = {
|
||||
id: NO_BACKEND_ID,
|
||||
name: "No Backend Available",
|
||||
host: "",
|
||||
apiKey: "",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
export function isNoBackend(backend: Backend): boolean {
|
||||
return backend.id === NO_BACKEND_ID;
|
||||
}
|
||||
|
||||
function pickFallbackBackend(backends: Backend[]): Backend {
|
||||
return backends[0] ?? NO_BACKEND;
|
||||
}
|
||||
|
||||
function computeSnapshot(
|
||||
@@ -48,7 +57,7 @@ function computeSnapshot(
|
||||
|
||||
// @spec BM-003 — Fallback on active backend removal
|
||||
if (!activeBackend) {
|
||||
activeBackend = pickLocalBackend(backends);
|
||||
activeBackend = pickFallbackBackend(backends);
|
||||
activeOrgId = null;
|
||||
}
|
||||
|
||||
@@ -79,15 +88,13 @@ export function getActiveBackend(): ResolvedActiveBackend {
|
||||
*
|
||||
* Most of the GUI's services (settings reads/writes, conversation CRUD,
|
||||
* skills/MCP/secrets, etc.) speak the local agent-server's protocol —
|
||||
* they would fail against a cloud host. When the user has chosen a
|
||||
* cloud backend as active, those calls fall back to the first registered
|
||||
* local backend (or the env-derived default if none exists). Cloud-only
|
||||
* call sites import `getActiveBackend` directly.
|
||||
* they would fail against a cloud host. Only the active backend is eligible:
|
||||
* a cloud selection must not borrow another registered local backend.
|
||||
*/
|
||||
export function getEffectiveLocalBackend(): Backend {
|
||||
export function getEffectiveLocalBackend(): Backend | null {
|
||||
const active = snapshot.active.backend;
|
||||
if (active.kind === "local") return active;
|
||||
return pickLocalBackend(snapshot.backends);
|
||||
if (active.kind === "local" && !isNoBackend(active)) return active;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRegisteredBackends(): Backend[] {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getAgentServerSessionApiKey } from "../agent-server-config";
|
||||
import { DEFAULT_LOCAL_BACKEND_ID } from "./default-backend";
|
||||
import type { Backend } from "./types";
|
||||
|
||||
/**
|
||||
@@ -9,13 +7,6 @@ import type { Backend } from "./types";
|
||||
* token in the `Authorization` header.
|
||||
*/
|
||||
export function buildAuthHeaders(backend: Backend): Record<string, string> {
|
||||
if (backend.kind === "local" && backend.id === DEFAULT_LOCAL_BACKEND_ID) {
|
||||
const configuredSessionApiKey = getAgentServerSessionApiKey();
|
||||
if (configuredSessionApiKey) {
|
||||
return { "X-Session-API-Key": configuredSessionApiKey };
|
||||
}
|
||||
}
|
||||
|
||||
if (!backend.apiKey) return {};
|
||||
|
||||
if (backend.kind === "cloud") {
|
||||
|
||||
@@ -6,35 +6,33 @@ import type { Backend } from "./types";
|
||||
|
||||
/**
|
||||
* Stable id for the default local backend that is auto-seeded into the
|
||||
* registry on a fresh install. After seeding, this backend is a normal
|
||||
* registered entry — the user can rename it, edit its host/api key, or
|
||||
* remove it like any other backend.
|
||||
*
|
||||
* The id is also used by `saveAgentServerConfig` to keep the registry
|
||||
* entry in sync with the legacy `openhands-agent-server-config` storage
|
||||
* that the recovery / agent-server settings page edits.
|
||||
* registry when the launcher provides both a backend host and API key.
|
||||
* After seeding, this backend is a normal registered entry — the user can
|
||||
* rename it, edit its host/api key, or remove it like any other backend.
|
||||
*/
|
||||
export const DEFAULT_LOCAL_BACKEND_ID = "default-local";
|
||||
|
||||
export const DEFAULT_LOCAL_BACKEND_NAME = "Local";
|
||||
|
||||
/**
|
||||
* Construct the default local backend from environment / agent-server
|
||||
* config (`VITE_BACKEND_BASE_URL`, `VITE_SESSION_API_KEY`, plus the
|
||||
* `openhands-agent-server-config` localStorage overrides).
|
||||
* Construct the default local backend from environment/runtime config.
|
||||
* Returns null unless both a backend location and API key are available.
|
||||
*
|
||||
* Used in two places:
|
||||
* 1. As the seed entry written to `openhands-backends` on first load.
|
||||
* 2. As a last-resort fallback inside the active store when the
|
||||
* registry has no local backend at all (e.g. the user removed
|
||||
* every entry). The synthetic fallback is never persisted.
|
||||
* Used as the seed entry written to `openhands-backends` on first load;
|
||||
* if it returns null, onboarding is responsible for collecting backend
|
||||
* connection details from the user.
|
||||
*/
|
||||
export function makeDefaultLocalBackend(): Backend {
|
||||
export function makeDefaultLocalBackend(): Backend | null {
|
||||
const host = getAgentServerBaseUrl();
|
||||
const apiKey = getAgentServerSessionApiKey();
|
||||
|
||||
if (!host || !apiKey) return null;
|
||||
|
||||
return {
|
||||
id: DEFAULT_LOCAL_BACKEND_ID,
|
||||
name: DEFAULT_LOCAL_BACKEND_NAME,
|
||||
host: getAgentServerBaseUrl(),
|
||||
apiKey: getAgentServerSessionApiKey() ?? "",
|
||||
host,
|
||||
apiKey,
|
||||
kind: "local",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { syncBakedSessionApiKey } from "../agent-server-config";
|
||||
import { makeDefaultLocalBackend } from "./default-backend";
|
||||
import {
|
||||
DEFAULT_LOCAL_BACKEND_ID,
|
||||
DEFAULT_LOCAL_BACKEND_NAME,
|
||||
makeDefaultLocalBackend,
|
||||
} from "./default-backend";
|
||||
import type { Backend, BackendKind, BackendSelection } from "./types";
|
||||
|
||||
export const BACKENDS_STORAGE_KEY = "openhands-backends";
|
||||
export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";
|
||||
|
||||
const LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY = "openhands-agent-server-config";
|
||||
|
||||
function isValidKind(value: unknown): value is BackendKind {
|
||||
return value === "local" || value === "cloud";
|
||||
}
|
||||
@@ -22,35 +27,103 @@ function isValidBackend(value: unknown): value is Backend {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeHostForComparison(host: string): string {
|
||||
function normalizeLegacyBaseUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) return null;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
|
||||
function readLegacyBackend(): Backend | null {
|
||||
const raw = window.localStorage.getItem(
|
||||
LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY,
|
||||
);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return new URL(host).origin;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const host = normalizeLegacyBaseUrl(parsed.baseUrl);
|
||||
const apiKey =
|
||||
typeof parsed.sessionApiKey === "string"
|
||||
? parsed.sessionApiKey.trim()
|
||||
: "";
|
||||
|
||||
if (!host || !apiKey) return null;
|
||||
|
||||
return {
|
||||
id: DEFAULT_LOCAL_BACKEND_ID,
|
||||
name: DEFAULT_LOCAL_BACKEND_NAME,
|
||||
host,
|
||||
apiKey,
|
||||
kind: "local",
|
||||
};
|
||||
} catch {
|
||||
return host.replace(/\/+$/, "");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncDefaultLocalBackendAuth(backend: Backend): Backend {
|
||||
function clearLegacyBackendConfig(): void {
|
||||
window.localStorage.removeItem(LEGACY_AGENT_SERVER_CONFIG_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function seedBackends(backends: Backend[]): Backend[] {
|
||||
writeStoredBackends(backends);
|
||||
clearLegacyBackendConfig();
|
||||
return backends;
|
||||
}
|
||||
|
||||
function isLoopbackUrl(value: string): boolean {
|
||||
try {
|
||||
const { hostname } = new URL(value);
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSyncLauncherDefaultLocalBackend(
|
||||
backend: Backend,
|
||||
defaultBackend: Backend,
|
||||
): boolean {
|
||||
if (backend.id !== DEFAULT_LOCAL_BACKEND_ID || backend.kind !== "local") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
backend.host === defaultBackend.host ||
|
||||
(isLoopbackUrl(backend.host) && isLoopbackUrl(defaultBackend.host))
|
||||
);
|
||||
}
|
||||
|
||||
function syncLauncherDefaultLocalBackend(backends: Backend[]): Backend[] {
|
||||
const defaultBackend = makeDefaultLocalBackend();
|
||||
if (!defaultBackend) return backends;
|
||||
|
||||
if (
|
||||
backend.id !== defaultBackend.id ||
|
||||
backend.kind !== "local" ||
|
||||
!defaultBackend.apiKey ||
|
||||
normalizeHostForComparison(backend.host) !==
|
||||
normalizeHostForComparison(defaultBackend.host)
|
||||
) {
|
||||
return backend;
|
||||
}
|
||||
let didSync = false;
|
||||
const syncedBackends = backends.map((backend) => {
|
||||
if (!shouldSyncLauncherDefaultLocalBackend(backend, defaultBackend)) {
|
||||
return backend;
|
||||
}
|
||||
|
||||
if (backend.apiKey === defaultBackend.apiKey) {
|
||||
return backend;
|
||||
}
|
||||
if (backend.apiKey === defaultBackend.apiKey) return backend;
|
||||
|
||||
return {
|
||||
...backend,
|
||||
apiKey: defaultBackend.apiKey,
|
||||
};
|
||||
didSync = true;
|
||||
return {
|
||||
...backend,
|
||||
apiKey: defaultBackend.apiKey,
|
||||
};
|
||||
});
|
||||
|
||||
if (!didSync) return backends;
|
||||
|
||||
writeStoredBackends(syncedBackends);
|
||||
return syncedBackends;
|
||||
}
|
||||
|
||||
export function writeStoredBackends(backends: Backend[]): void {
|
||||
@@ -65,26 +138,20 @@ export function writeStoredBackends(backends: Backend[]): void {
|
||||
export function readStoredBackends(): Backend[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
// Ensure the baked-in session API key (VITE_SESSION_API_KEY) is synced
|
||||
// into the legacy `openhands-agent-server-config` localStorage entry
|
||||
// BEFORE we read the backend registry. This matters when the user
|
||||
// restarts the stack with a different LOCAL_BACKEND_API_KEY — without
|
||||
// this call the stale key in `openhands-agent-server-config` shadows
|
||||
// the new baked key, making `makeDefaultLocalBackend()` (and the
|
||||
// downstream `syncDefaultLocalBackendAuth`) read the wrong value.
|
||||
syncBakedSessionApiKey();
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY);
|
||||
|
||||
// First install: the storage key has never been written. Seed the
|
||||
// registry with one default local backend derived from the env /
|
||||
// agent-server-config so the user has something to talk to out of
|
||||
// the box.
|
||||
// First install: migrate one legacy local backend if present, otherwise
|
||||
// seed only when the launcher supplied enough information for a usable
|
||||
// local backend.
|
||||
if (raw === null) {
|
||||
const seeded = [makeDefaultLocalBackend()];
|
||||
writeStoredBackends(seeded);
|
||||
return seeded;
|
||||
const legacyBackend = readLegacyBackend();
|
||||
if (legacyBackend) return seedBackends([legacyBackend]);
|
||||
|
||||
const defaultBackend = makeDefaultLocalBackend();
|
||||
if (!defaultBackend) return [];
|
||||
|
||||
return seedBackends([defaultBackend]);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -92,22 +159,16 @@ export function readStoredBackends(): Backend[] {
|
||||
const valid = parsed.filter(isValidBackend);
|
||||
|
||||
// If the stored array is empty (or everything in it failed validation),
|
||||
// re-seed with the default Local backend so the user always has a
|
||||
// working entry pointing at VITE_SESSION_API_KEY. With the dev scripts
|
||||
// persisting that key to ~/.openhands/agent-canvas/session-api-key.txt,
|
||||
// re-seeding is safe — the seeded entry will keep working across
|
||||
// restarts instead of going stale.
|
||||
// only re-seed when the launcher supplied both a host and API key.
|
||||
if (valid.length === 0) {
|
||||
const seeded = [makeDefaultLocalBackend()];
|
||||
writeStoredBackends(seeded);
|
||||
return seeded;
|
||||
}
|
||||
const defaultBackend = makeDefaultLocalBackend();
|
||||
if (!defaultBackend) return [];
|
||||
|
||||
const synced = valid.map(syncDefaultLocalBackendAuth);
|
||||
if (synced.some((backend, index) => backend !== valid[index])) {
|
||||
writeStoredBackends(synced);
|
||||
return seedBackends([defaultBackend]);
|
||||
}
|
||||
|
||||
const synced = syncLauncherDefaultLocalBackend(valid);
|
||||
clearLegacyBackendConfig();
|
||||
return synced;
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -52,9 +52,8 @@ function getActiveCloudBackend(): Backend {
|
||||
|
||||
/**
|
||||
* Search the cloud app-conversations list. Mirrors the local
|
||||
* `AgentServerConversationService.searchConversations` interface but routes
|
||||
* through the bundled agent-server's cloud proxy and hits the cloud
|
||||
* endpoint `/api/v1/app-conversations/search`.
|
||||
* `AgentServerConversationService.searchConversations` interface but calls
|
||||
* the cloud endpoint `/api/v1/app-conversations/search`.
|
||||
*/
|
||||
export async function searchCloudConversations(
|
||||
limit: number = 20,
|
||||
@@ -114,7 +113,7 @@ export async function batchGetCloudConversations(
|
||||
*
|
||||
* This path does NOT use encrypted-settings round-tripping. Secrets stay
|
||||
* server-side on the cloud backend — the only auth carried is the cloud bearer
|
||||
* token (via the proxy's headers), and the conversation runtime is
|
||||
* token, and the conversation runtime is
|
||||
* provisioned with its own ephemeral session_api_key returned in the
|
||||
* task.
|
||||
*/
|
||||
@@ -134,7 +133,7 @@ export async function createCloudAppConversation(
|
||||
/**
|
||||
* Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors
|
||||
* the local `AgentServerConversationService.downloadConversation` interface but
|
||||
* routes through the bundled agent-server's cloud proxy and hits
|
||||
* calls
|
||||
* `GET /api/v1/app-conversations/{id}/download`, which returns
|
||||
* `application/zip` with `Content-Disposition` set by the cloud backend.
|
||||
*/
|
||||
@@ -152,8 +151,7 @@ export async function downloadCloudConversation(
|
||||
|
||||
/**
|
||||
* Delete a v1 app-conversation on the cloud backend. Mirrors the local
|
||||
* `AgentServerConversationService.deleteConversation` interface but routes
|
||||
* through the bundled agent-server's cloud proxy and hits
|
||||
* `AgentServerConversationService.deleteConversation` interface but calls
|
||||
* `DELETE /api/v1/app-conversations/{id}`, which returns a JSON
|
||||
* `Success` envelope (discarded here — the caller only needs to know
|
||||
* the request didn't error).
|
||||
@@ -171,8 +169,7 @@ export async function deleteCloudConversation(
|
||||
|
||||
/**
|
||||
* Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
|
||||
* OpenHands' `AgentServerConversationService.updateConversationPublicFlag` —
|
||||
* routes through the bundled agent-server's cloud proxy and hits
|
||||
* OpenHands' `AgentServerConversationService.updateConversationPublicFlag`:
|
||||
* `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
|
||||
* the updated conversation.
|
||||
*/
|
||||
@@ -192,8 +189,7 @@ export async function updateCloudConversationPublicFlag(
|
||||
|
||||
/**
|
||||
* Pause the cloud sandbox backing a v1 app-conversation. Mirrors
|
||||
* OpenHands' `SandboxService.pauseSandbox` — routes through the
|
||||
* bundled agent-server's cloud proxy and hits
|
||||
* OpenHands' `SandboxService.pauseSandbox`:
|
||||
* `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops
|
||||
* the runtime owning the conversation.
|
||||
*/
|
||||
@@ -208,8 +204,7 @@ export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
|
||||
|
||||
/**
|
||||
* Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox`
|
||||
* — routes through the bundled agent-server's cloud proxy and hits
|
||||
* `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS.
|
||||
* by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS.
|
||||
*
|
||||
* This is the correct endpoint for waking a PAUSED sandbox. It is a
|
||||
* lightweight unpause — NOT the same as creating a new start task via
|
||||
|
||||
@@ -38,8 +38,7 @@ function resolveBackend(backend?: Backend): Backend {
|
||||
* cloud backend; pass `backend` explicitly to fetch for an inactive cloud
|
||||
* (used by the selector to flatten all cloud rows).
|
||||
*
|
||||
* Routed through the bundled agent-server's `/api/cloud-proxy` to avoid
|
||||
* cross-origin browser calls.
|
||||
* Calls the cloud API directly with the backend's bearer token.
|
||||
*/
|
||||
export async function getCloudOrganizations(
|
||||
backend?: Backend,
|
||||
|
||||
+33
-20
@@ -1,9 +1,10 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
getActiveBackend,
|
||||
getEffectiveLocalBackend,
|
||||
} from "../backend-registry/active-store";
|
||||
import { getAgentServerHeaders } from "../agent-server-config";
|
||||
getAgentServerBaseUrl,
|
||||
getAgentServerHeaders,
|
||||
} from "../agent-server-config";
|
||||
import { getActiveBackend } from "../backend-registry/active-store";
|
||||
import { NoBackendAvailableError } from "../agent-server-client-options";
|
||||
import { buildAuthHeaders } from "../backend-registry/auth";
|
||||
import type { Backend } from "../backend-registry/types";
|
||||
|
||||
@@ -62,22 +63,17 @@ function buildUpstreamAuthHeaders(
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a cloud-proxy envelope to the local agent-server. The local server
|
||||
* forwards the request to the upstream host server-side, which sidesteps
|
||||
* the cross-origin restrictions that would block a direct browser → cloud
|
||||
* or browser → runtime-sandbox call.
|
||||
* Send a cloud request. App-host calls (`backend.host`) go directly to the
|
||||
* cloud API with the cloud backend's auth headers. Runtime-sandbox calls pass
|
||||
* `hostOverride`, and those still go through `/api/cloud-proxy` because the
|
||||
* per-conversation runtime hosts are not the configured cloud app origin.
|
||||
*
|
||||
* Auth headers (bearer or session-api-key) are attached server-side; they
|
||||
* never cross an origin boundary in the browser.
|
||||
* App-host auth headers are sent directly to the cloud host. Runtime auth
|
||||
* headers are carried in the proxy envelope and attached server-side.
|
||||
*/
|
||||
export async function callCloudProxy<TResponse = unknown>(
|
||||
req: CloudProxyRequest,
|
||||
): Promise<TResponse> {
|
||||
const local = getEffectiveLocalBackend();
|
||||
const localAuthHeaders = {
|
||||
...buildAuthHeaders(local),
|
||||
...getAgentServerHeaders(),
|
||||
};
|
||||
// Send `X-Org-Id` so the upstream scopes per-request to the org the user
|
||||
// selected locally, instead of the user's globally-shared
|
||||
// `current_org_id` on the cloud backend. Restricted to calls against the active
|
||||
@@ -97,12 +93,29 @@ export async function callCloudProxy<TResponse = unknown>(
|
||||
};
|
||||
const upstreamHost = req.hostOverride ?? req.backend.host;
|
||||
|
||||
// Talk directly to the local agent-server, bypassing the global
|
||||
// local agent-server client configuration (which would otherwise read host + auth
|
||||
// from the active backend — wrong for this call: we need the local
|
||||
// backend's host and session key explicitly, not the active one).
|
||||
if (!req.hostOverride) {
|
||||
const response = await axios.request<TResponse>({
|
||||
url: `${upstreamHost.replace(/\/+$/, "")}${req.path}`,
|
||||
method: req.method,
|
||||
headers: upstreamHeaders,
|
||||
...(req.body !== undefined ? { data: req.body } : {}),
|
||||
timeout: (req.timeoutSeconds ?? 30) * 1000,
|
||||
...(req.responseType ? { responseType: req.responseType } : {}),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const proxyBaseUrl = getAgentServerBaseUrl();
|
||||
if (!proxyBaseUrl) throw new NoBackendAvailableError();
|
||||
const localAuthHeaders = getAgentServerHeaders();
|
||||
|
||||
// Talk to the configured app/ingress origin that exposes /api/cloud-proxy.
|
||||
// Do not resolve this through the backend registry: when the active backend
|
||||
// is cloud, borrowing some other registered local backend would silently
|
||||
// route cloud traffic through the wrong user-configured server.
|
||||
const response = await axios.post<TResponse>(
|
||||
`${local.host.replace(/\/+$/, "")}/api/cloud-proxy`,
|
||||
`${proxyBaseUrl.replace(/\/+$/, "")}/api/cloud-proxy`,
|
||||
{
|
||||
host: upstreamHost,
|
||||
method: req.method,
|
||||
|
||||
@@ -13,9 +13,9 @@ function getActiveCloudBackend(): Backend {
|
||||
|
||||
/**
|
||||
* Batch-fetch cloud sandboxes by id. Mirrors OpenHands'
|
||||
* `SandboxService.batchGetSandboxes` — routes through the bundled
|
||||
* agent-server's cloud proxy and hits `GET /api/v1/sandboxes?id=...` on
|
||||
* the cloud backend, returning each `SandboxInfo` (or null if not found).
|
||||
* `SandboxService.batchGetSandboxes` by calling
|
||||
* `GET /api/v1/sandboxes?id=...` on the cloud backend, returning each
|
||||
* `SandboxInfo` (or null if not found).
|
||||
*
|
||||
* The returned `SandboxInfo.exposed_urls` carry the cloud-computed,
|
||||
* publicly-reachable URLs for the sandbox's services (VSCODE,
|
||||
|
||||
@@ -19,10 +19,9 @@ function getActiveCloudBackend(): Backend {
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every page of the cloud `/api/v1/secrets/search` endpoint via the
|
||||
* bundled `/api/cloud-proxy` and return the merged list. The cloud shape
|
||||
* (name + description) matches `CustomSecretWithoutValue`, so items pass
|
||||
* through unchanged.
|
||||
* Walk every page of the cloud `/api/v1/secrets/search` endpoint and return
|
||||
* the merged list. The cloud shape (name + description) matches
|
||||
* `CustomSecretWithoutValue`, so items pass through unchanged.
|
||||
*/
|
||||
export async function fetchCloudSecrets(): Promise<CustomSecretWithoutValue[]> {
|
||||
const backend = getActiveCloudBackend();
|
||||
|
||||
@@ -19,11 +19,11 @@ function getActiveCloudBackend(): Backend {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full list of skills from the cloud backend via the bundled
|
||||
* /api/cloud-proxy. The cloud endpoint is paginated (page_id cursor); we
|
||||
* walk all pages so the settings UI gets a complete list in one call. The
|
||||
* cloud SkillInfo shape (name/type/source/triggers) matches the GUI's
|
||||
* SkillInfo type, so items are passed through unchanged.
|
||||
* Fetch the full list of skills from the cloud backend. The cloud endpoint is
|
||||
* paginated (page_id cursor); we walk all pages so the settings UI gets a
|
||||
* complete list in one call. The cloud SkillInfo shape
|
||||
* (name/type/source/triggers) matches the GUI's SkillInfo type, so items are
|
||||
* passed through unchanged.
|
||||
*/
|
||||
export async function fetchCloudSkills(): Promise<SkillInfo[]> {
|
||||
const backend = getActiveCloudBackend();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ConversationSortOrder } from "@openhands/typescript-client";
|
||||
import {
|
||||
ConversationSortOrder,
|
||||
type LLMConfig,
|
||||
} from "@openhands/typescript-client";
|
||||
import {
|
||||
ConversationClient,
|
||||
FileClient,
|
||||
@@ -37,7 +40,10 @@ import {
|
||||
toConversationPage,
|
||||
} from "../agent-server-adapter";
|
||||
import { GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { getAgentServerClientOptions } from "../agent-server-client-options";
|
||||
import {
|
||||
getAgentServerClientOptions,
|
||||
NoBackendAvailableError,
|
||||
} from "../agent-server-client-options";
|
||||
import SettingsService from "../settings-service/settings-service.api";
|
||||
import {
|
||||
ConversationMetadata,
|
||||
@@ -385,6 +391,8 @@ class AgentServerConversationService {
|
||||
const data = await new ConversationClient(
|
||||
getAgentServerClientOptions(),
|
||||
).createConversation<DirectConversationInfo>(payload);
|
||||
const localBackend = getEffectiveLocalBackend();
|
||||
if (!localBackend) throw new NoBackendAvailableError();
|
||||
|
||||
if (metadata?.selected_repository || workingDirOverride) {
|
||||
// The agent-server runtime has no concept of selected repo/branch/
|
||||
@@ -407,7 +415,7 @@ class AgentServerConversationService {
|
||||
status: "READY",
|
||||
detail: null,
|
||||
app_conversation_id: data.id,
|
||||
agent_server_url: getEffectiveLocalBackend().host,
|
||||
agent_server_url: localBackend.host,
|
||||
request: {
|
||||
initial_message: payload.initial_message as
|
||||
| AppConversationStartRequest["initial_message"]
|
||||
@@ -679,10 +687,21 @@ class AgentServerConversationService {
|
||||
return;
|
||||
}
|
||||
|
||||
await new ConversationClient(getAgentServerClientOptions()).switchProfile(
|
||||
conversationId,
|
||||
const clientOptions = getAgentServerClientOptions();
|
||||
const conversationClient = new ConversationClient(clientOptions);
|
||||
const profile = await new ProfilesClient(clientOptions).getProfile(
|
||||
profileName,
|
||||
{ exposeSecrets: "encrypted" },
|
||||
);
|
||||
const model =
|
||||
typeof profile.config.model === "string" ? profile.config.model : "";
|
||||
if (!model) throw new Error(`Profile '${profileName}' has no model.`);
|
||||
await conversationClient.switchLLM(conversationId, {
|
||||
...profile.config,
|
||||
model,
|
||||
// Avoid stale first-write-wins entries in the backend LLM registry.
|
||||
usage_id: `profile:${profileName}:${uuidv4()}`,
|
||||
} as LLMConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,14 +5,9 @@
|
||||
* The flow allows users to authenticate in their browser while the
|
||||
* application polls for the resulting API key.
|
||||
*
|
||||
* All device flow requests are proxied through the local agent-server's
|
||||
* cloud-proxy endpoint to avoid CORS issues. Since a local agent-server
|
||||
* is required to use the frontend, the proxy is always available.
|
||||
* Device flow requests go directly to the configured OpenHands Cloud host.
|
||||
*/
|
||||
|
||||
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
|
||||
import { buildAuthHeaders } from "./backend-registry/auth";
|
||||
|
||||
export class DeviceFlowError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -74,10 +69,9 @@ export function isOpenHandsCloudHost(host: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a proxied request through the local agent-server's cloud-proxy endpoint.
|
||||
* This avoids CORS issues when calling OpenHands Cloud endpoints.
|
||||
* Make a direct request to the OpenHands Cloud device-flow endpoint.
|
||||
*/
|
||||
async function makeProxiedRequest(
|
||||
async function makeCloudRequest(
|
||||
upstreamHost: string,
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
@@ -85,22 +79,22 @@ async function makeProxiedRequest(
|
||||
contentType?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const local = getEffectiveLocalBackend();
|
||||
const proxyUrl = `${local.host.replace(/\/+$/, "")}/api/cloud-proxy`;
|
||||
const requestBody =
|
||||
body === undefined
|
||||
? undefined
|
||||
: typeof body === "string" ||
|
||||
body instanceof Blob ||
|
||||
body instanceof FormData ||
|
||||
body instanceof URLSearchParams
|
||||
? body
|
||||
: JSON.stringify(body);
|
||||
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: "POST",
|
||||
const response = await fetch(`${upstreamHost.replace(/\/+$/, "")}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...buildAuthHeaders(local),
|
||||
...(contentType ? { "Content-Type": contentType } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
host: upstreamHost,
|
||||
method,
|
||||
path,
|
||||
headers: contentType ? { "Content-Type": contentType } : {},
|
||||
body: body ?? null,
|
||||
}),
|
||||
body: requestBody,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -109,7 +103,7 @@ async function makeProxiedRequest(
|
||||
|
||||
/**
|
||||
* Start the OAuth 2.0 Device Flow by requesting a device code.
|
||||
* All requests are proxied through the local agent-server to avoid CORS issues.
|
||||
* Requests are sent directly to the cloud host.
|
||||
*
|
||||
* @param host - The cloud backend host URL (e.g., "https://app.all-hands.dev")
|
||||
* @returns DeviceAuthorizationResponse with device_code, user_code, verification URLs, etc.
|
||||
@@ -121,7 +115,7 @@ export async function startDeviceFlow(
|
||||
const normalizedHost = host.replace(/\/+$/, "");
|
||||
|
||||
try {
|
||||
const response = await makeProxiedRequest(
|
||||
const response = await makeCloudRequest(
|
||||
normalizedHost,
|
||||
"POST",
|
||||
"/oauth/device/authorize",
|
||||
@@ -180,7 +174,7 @@ export interface PollOptions {
|
||||
|
||||
/**
|
||||
* Poll for the API key after user authorization.
|
||||
* All requests are proxied through the local agent-server to avoid CORS issues.
|
||||
* Requests are sent directly to the cloud host.
|
||||
*
|
||||
* @param host - The cloud backend host URL
|
||||
* @param deviceCode - The device code from startDeviceFlow
|
||||
@@ -211,7 +205,7 @@ export async function pollForToken(
|
||||
device_code: deviceCode,
|
||||
}).toString();
|
||||
|
||||
const response = await makeProxiedRequest(
|
||||
const response = await makeCloudRequest(
|
||||
normalizedHost,
|
||||
"POST",
|
||||
"/oauth/device/token",
|
||||
|
||||
@@ -29,9 +29,9 @@ import type {
|
||||
* `/api/conversations/{id}/events/respond_to_confirmation`. Auth on
|
||||
* these endpoints is `X-Session-API-Key`, not `Authorization: Bearer`.
|
||||
*
|
||||
* Both go through the bundled local agent-server's `/api/cloud-proxy`,
|
||||
* which sidesteps the cross-origin restrictions that block the GUI at
|
||||
* `localhost` from talking directly to either the cloud backend or the runtime.
|
||||
* App API calls go directly to the cloud backend with bearer auth. Runtime
|
||||
* sandbox calls go through `/api/cloud-proxy`, which avoids depending on CORS
|
||||
* for per-conversation runtime hosts.
|
||||
*
|
||||
* Local mode keeps the existing typescript-client path: it targets the
|
||||
* conversation's host directly via typed client classes.
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsClient } from "@openhands/typescript-client/clients";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { saveAgentServerConfig } from "#/api/agent-server-config";
|
||||
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
|
||||
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
*/
|
||||
export default function ApiKeyEntryScreen() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { active, updateBackend } = useActiveBackendContext();
|
||||
const { active, addBackend, updateBackend } = useActiveBackendContext();
|
||||
|
||||
const host = window.location.origin;
|
||||
|
||||
@@ -56,11 +56,11 @@ export default function ApiKeyEntryScreen() {
|
||||
|
||||
setConnectionStatus("success");
|
||||
|
||||
updateBackend(active.backend.id, payload);
|
||||
saveAgentServerConfig({
|
||||
baseUrl: payload.host,
|
||||
sessionApiKey: payload.apiKey,
|
||||
});
|
||||
if (isNoBackend(active.backend)) {
|
||||
addBackend(payload);
|
||||
} else {
|
||||
updateBackend(active.backend.id, payload);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err: unknown) {
|
||||
@@ -77,7 +77,7 @@ export default function ApiKeyEntryScreen() {
|
||||
setIsValidating(false);
|
||||
}
|
||||
},
|
||||
[active.backend.id, updateBackend, t],
|
||||
[active.backend, addBackend, updateBackend, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -118,6 +118,42 @@ function isValidHostUrl(host: string): boolean {
|
||||
|
||||
const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev";
|
||||
|
||||
function getConnectionTestFailedTitle(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
host: string,
|
||||
): string {
|
||||
return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, {
|
||||
host,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
|
||||
function getConnectionErrorDetail(error: unknown): string | null {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectionTestFailedMessage(title: string, error: unknown): string {
|
||||
const detail = getConnectionErrorDetail(error);
|
||||
return detail ? `${title}\n${detail}` : title;
|
||||
}
|
||||
|
||||
async function testBackendConnection(
|
||||
backend: Pick<Backend, "host" | "apiKey" | "kind">,
|
||||
): Promise<void> {
|
||||
// Cloud backends authenticate via OAuth; preflight GET is not applicable.
|
||||
if (backend.kind !== "local") return;
|
||||
|
||||
await new ServerClient(
|
||||
getAgentServerClientOptions({
|
||||
host: backend.host,
|
||||
sessionApiKey: backend.apiKey || null,
|
||||
timeout: 5000,
|
||||
}),
|
||||
).getServerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live status row for the edit form: shows a connection dot, a
|
||||
* "Local"/"Cloud" label, and the agent server's reported version when
|
||||
@@ -245,6 +281,7 @@ export interface BackendFormProps {
|
||||
*/
|
||||
renderActions?: (state: {
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
testIdRoot: string;
|
||||
}) => React.ReactNode;
|
||||
/** Used to disambiguate test ids across the same screen. */
|
||||
@@ -292,6 +329,10 @@ export function BackendForm({
|
||||
const [name, setName] = React.useState(backend?.name ?? "");
|
||||
const [host, setHost] = React.useState(backend?.host ?? "");
|
||||
const [apiKey, setApiKey] = React.useState(backend?.apiKey ?? "");
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
// Inline validation: only show errors after the user has left a field.
|
||||
const [nameTouched, setNameTouched] = React.useState(false);
|
||||
@@ -322,6 +363,8 @@ export function BackendForm({
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!canSubmit) {
|
||||
// Mark all validated fields as touched so inline errors become visible
|
||||
// (e.g. user pressed Enter before filling required fields).
|
||||
@@ -337,18 +380,34 @@ export function BackendForm({
|
||||
kind,
|
||||
};
|
||||
|
||||
if (onSubmitOverride) {
|
||||
await onSubmitOverride(payload);
|
||||
return;
|
||||
}
|
||||
setConnectionError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (mode === "edit" && backend) {
|
||||
updateBackend(backend.id, payload);
|
||||
} else {
|
||||
addBackend(payload);
|
||||
}
|
||||
try {
|
||||
if (onSubmitOverride) {
|
||||
await onSubmitOverride(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmitted();
|
||||
await testBackendConnection(payload);
|
||||
|
||||
if (mode === "edit" && backend) {
|
||||
updateBackend(backend.id, payload);
|
||||
} else {
|
||||
addBackend(payload);
|
||||
}
|
||||
|
||||
onSubmitted();
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
getConnectionTestFailedMessage(
|
||||
getConnectionTestFailedTitle(t, payload.host),
|
||||
error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -363,7 +422,10 @@ export function BackendForm({
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$NAME_LABEL)}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onChange={(value) => {
|
||||
setName(value);
|
||||
setConnectionError(null);
|
||||
}}
|
||||
onBlur={() => setNameTouched(true)}
|
||||
placeholder="Production"
|
||||
className="w-full"
|
||||
@@ -377,7 +439,14 @@ export function BackendForm({
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
||||
value={host}
|
||||
onChange={hostReadOnly ? undefined : setHost}
|
||||
onChange={
|
||||
hostReadOnly
|
||||
? undefined
|
||||
: (value) => {
|
||||
setHost(value);
|
||||
setConnectionError(null);
|
||||
}
|
||||
}
|
||||
onBlur={() => setHostTouched(true)}
|
||||
placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}
|
||||
className="w-full"
|
||||
@@ -392,17 +461,34 @@ export function BackendForm({
|
||||
type="password"
|
||||
label={t(I18nKey.BACKEND$KEY_LABEL)}
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
onChange={(value) => {
|
||||
setApiKey(value);
|
||||
setConnectionError(null);
|
||||
}}
|
||||
placeholder=""
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{connectionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid={`${testIdRoot}-error`}
|
||||
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{connectionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && backend && (
|
||||
<BackendStatusBadge backend={backend} testIdRoot={testIdRoot} />
|
||||
)}
|
||||
|
||||
{renderActions ? (
|
||||
renderActions({ canSubmit, testIdRoot })
|
||||
renderActions({
|
||||
canSubmit: canSubmit && !isSubmitting,
|
||||
isSubmitting,
|
||||
testIdRoot,
|
||||
})
|
||||
) : (
|
||||
<div className="flex justify-end gap-2 mt-2 w-full">
|
||||
<BrandButton
|
||||
@@ -416,7 +502,7 @@ export function BackendForm({
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!canSubmit}
|
||||
isDisabled={!canSubmit || isSubmitting}
|
||||
testId={`${testIdRoot}-submit`}
|
||||
>
|
||||
{t(I18nKey.BACKEND$SAVE)}
|
||||
@@ -457,6 +543,10 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [host, setHost] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
const kind: BackendKind = inferKindFromHost(host);
|
||||
const canSubmit =
|
||||
@@ -464,17 +554,35 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
|
||||
isValidHostUrl(host) &&
|
||||
(kind === "local" || apiKey.trim().length > 0);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
addBackend({
|
||||
if (!canSubmit || isSubmitting) return;
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
host: normalizeHost(host),
|
||||
apiKey: apiKey.trim(),
|
||||
kind,
|
||||
});
|
||||
redirectAfterAdd();
|
||||
onClose();
|
||||
};
|
||||
|
||||
setConnectionError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await testBackendConnection(payload);
|
||||
addBackend(payload);
|
||||
redirectAfterAdd();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setConnectionError(
|
||||
getConnectionTestFailedMessage(
|
||||
getConnectionTestFailedTitle(t, payload.host),
|
||||
error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -490,7 +598,10 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$NAME_LABEL)}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onChange={(value) => {
|
||||
setName(value);
|
||||
setConnectionError(null);
|
||||
}}
|
||||
placeholder="e.g. My Server"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -506,7 +617,10 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
|
||||
type="text"
|
||||
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
||||
value={host}
|
||||
onChange={setHost}
|
||||
onChange={(value) => {
|
||||
setHost(value);
|
||||
setConnectionError(null);
|
||||
}}
|
||||
placeholder="http://localhost:8000"
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -524,19 +638,34 @@ function ManualConnectionColumn({ onClose }: { onClose: () => void }) {
|
||||
type="password"
|
||||
label={t(I18nKey.BACKEND$KEY_LABEL)}
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
onChange={(value) => {
|
||||
setApiKey(value);
|
||||
setConnectionError(null);
|
||||
}}
|
||||
placeholder="sk-••••••••••"
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{connectionError ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="add-backend-error"
|
||||
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{connectionError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!canSubmit}
|
||||
isDisabled={!canSubmit || isSubmitting}
|
||||
testId="add-backend-submit"
|
||||
className="w-full text-center"
|
||||
>
|
||||
{t(I18nKey.BACKEND$CONNECT)}
|
||||
{isSubmitting
|
||||
? t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)
|
||||
: t(I18nKey.BACKEND$CONNECT)}
|
||||
</BrandButton>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMatch, useNavigate } from "react-router";
|
||||
import { Plus, Settings } from "lucide-react";
|
||||
import { Dropdown } from "#/ui/dropdown/dropdown";
|
||||
import { DropdownOption } from "#/ui/dropdown/types";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations";
|
||||
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
|
||||
@@ -53,6 +54,10 @@ function buildStatusPrefix(health: BackendHealth | undefined) {
|
||||
return <BackendStatusDot isConnected={health?.isConnected ?? null} />;
|
||||
}
|
||||
|
||||
function buildNoBackendPrefix() {
|
||||
return <BackendStatusDot isConnected="unavailable" />;
|
||||
}
|
||||
|
||||
function buildOptions(
|
||||
registered: Backend[],
|
||||
personalWorkspaceLabel: string,
|
||||
@@ -177,8 +182,12 @@ export function BackendSelector({
|
||||
],
|
||||
);
|
||||
|
||||
const noBackendSelected = isNoBackend(active.backend);
|
||||
const noBackendLabel = t(I18nKey.BACKEND$NO_BACKEND_AVAILABLE);
|
||||
const activeValue = makeOptionValue(active.backend.id, active.orgId);
|
||||
const activeOption = options.find((o) => o.value === activeValue);
|
||||
const activeOption = noBackendSelected
|
||||
? undefined
|
||||
: options.find((o) => o.value === activeValue);
|
||||
const isSettingsActive = Boolean(settingsMatch || settingsSubrouteMatch);
|
||||
const settingsLabel = t(I18nKey.SIDEBAR$SETTINGS);
|
||||
const isRightPanelShown = useConversationStore(
|
||||
@@ -206,7 +215,8 @@ export function BackendSelector({
|
||||
// X-Org-Id header sent by `callCloudProxy`, so the cloud UI's
|
||||
// org choice is never mutated as a side effect.
|
||||
React.useEffect(() => {
|
||||
if (active.backend.kind !== "cloud" || active.orgId) return;
|
||||
if (noBackendSelected || active.backend.kind !== "cloud" || active.orgId)
|
||||
return;
|
||||
const { backend } = active;
|
||||
const entry = cloudOrgs[backend.id];
|
||||
if (!entry || entry.orgs.length === 0) return;
|
||||
@@ -219,7 +229,7 @@ export function BackendSelector({
|
||||
if (target) {
|
||||
setActive(backend.id, target.id);
|
||||
}
|
||||
}, [active, cloudOrgs, currentUserIds, setActive]);
|
||||
}, [active, cloudOrgs, currentUserIds, setActive, noBackendSelected]);
|
||||
|
||||
const openAddBackendModal = React.useCallback(() => {
|
||||
if (onOpenAddBackend) {
|
||||
@@ -327,8 +337,10 @@ export function BackendSelector({
|
||||
defaultValue={
|
||||
activeOption ?? {
|
||||
value: activeValue,
|
||||
label: active.backend.name,
|
||||
prefix: buildStatusPrefix(healthByBackendId[active.backend.id]),
|
||||
label: noBackendSelected ? noBackendLabel : active.backend.name,
|
||||
prefix: noBackendSelected
|
||||
? buildNoBackendPrefix()
|
||||
: buildStatusPrefix(healthByBackendId[active.backend.id]),
|
||||
}
|
||||
}
|
||||
footer={addBackendFooter}
|
||||
@@ -340,7 +352,9 @@ export function BackendSelector({
|
||||
if (!item) return;
|
||||
void handleSelectBackend(item.value);
|
||||
}}
|
||||
placeholder={active.backend.name}
|
||||
placeholder={
|
||||
noBackendSelected ? noBackendLabel : active.backend.name
|
||||
}
|
||||
loading={someCloudLoading}
|
||||
options={options}
|
||||
className="h-10 px-2 py-0 bg-transparent border-transparent hover:bg-[var(--oh-surface-raised)] focus-within:bg-[var(--oh-surface-raised)] focus-within:border-transparent focus-within:ring-0"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface BackendStatusDotProps {
|
||||
/** `null` while the first probe is in flight. */
|
||||
isConnected: boolean | null;
|
||||
isConnected: boolean | null | "unavailable";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ export function BackendStatusDot({
|
||||
let color: string;
|
||||
let label: string;
|
||||
let status: string;
|
||||
if (isConnected === true) {
|
||||
if (isConnected === "unavailable") {
|
||||
color = "bg-[var(--oh-text-tertiary)]";
|
||||
label = "No Backend Available";
|
||||
status = "unavailable";
|
||||
} else if (isConnected === true) {
|
||||
color = "bg-[var(--oh-status-success)]";
|
||||
label = "Connected";
|
||||
status = "connected";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
isInvalidBackendApiKeyHealthError,
|
||||
useBackendsHealth,
|
||||
type BackendHealth,
|
||||
} from "#/hooks/query/use-backends-health";
|
||||
@@ -77,13 +78,30 @@ interface BackendRowProps {
|
||||
|
||||
function BackendRow({ backend, health, onEdit, onRemove }: BackendRowProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError);
|
||||
let statusLabel: string;
|
||||
let statusClassName = "text-[var(--oh-muted)]";
|
||||
|
||||
if (isInvalidApiKey) {
|
||||
statusLabel = t(I18nKey.AUTH$INVALID_KEY);
|
||||
statusClassName = "text-red-300";
|
||||
} else if (health?.isConnected === true) {
|
||||
statusLabel = t(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED);
|
||||
statusClassName = "text-green-300";
|
||||
} else if (health?.isConnected === false) {
|
||||
statusLabel = t(I18nKey.ONBOARDING$BACKEND_STATUS_DISCONNECTED);
|
||||
statusClassName = "text-red-300";
|
||||
} else {
|
||||
statusLabel = t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING);
|
||||
}
|
||||
const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null);
|
||||
|
||||
return (
|
||||
<li
|
||||
className="flex items-center gap-3 px-3 py-3"
|
||||
data-testid={`manage-backends-row-${backend.name}`}
|
||||
>
|
||||
<BackendStatusDot isConnected={health?.isConnected ?? null} />
|
||||
<BackendStatusDot isConnected={dotStatus} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-white">{backend.name}</span>
|
||||
@@ -92,6 +110,12 @@ function BackendRow({ backend, health, onEdit, onRemove }: BackendRowProps) {
|
||||
<span className="truncate text-xs text-[var(--oh-muted)]">
|
||||
{backend.host}
|
||||
</span>
|
||||
<span
|
||||
data-testid={`manage-backends-status-${backend.name}`}
|
||||
className={cn("truncate text-xs", statusClassName)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-2 py-1 rounded-full text-[11px] uppercase tracking-wide text-[var(--oh-text-tertiary)] bg-[var(--oh-surface)] border border-[var(--oh-border)]">
|
||||
{backend.kind === "cloud"
|
||||
|
||||
@@ -78,8 +78,8 @@ interface OnboardingModalProps {
|
||||
* Top-level onboarding modal for first-time users.
|
||||
*
|
||||
* The flow is a fixed sequence of four steps:
|
||||
* 0. Choose agent
|
||||
* 1. Check backend
|
||||
* 0. Check backend
|
||||
* 1. Choose agent
|
||||
* 2. Set up LLM
|
||||
* 3. Say hello (creates a fresh conversation, then closes)
|
||||
*
|
||||
@@ -141,15 +141,16 @@ export function OnboardingModal({ onClose }: OnboardingModalProps) {
|
||||
className="relative overflow-clip"
|
||||
>
|
||||
<Slide index={0} currentStep={currentStep}>
|
||||
<CheckBackendStep onNext={goNext} />
|
||||
</Slide>
|
||||
<Slide index={1} currentStep={currentStep}>
|
||||
<ChooseAgentStep
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelect={setSelectedAgentId}
|
||||
onNext={goNext}
|
||||
onBack={goBack}
|
||||
/>
|
||||
</Slide>
|
||||
<Slide index={1} currentStep={currentStep}>
|
||||
<CheckBackendStep onBack={goBack} onNext={goNext} />
|
||||
</Slide>
|
||||
<Slide index={SETUP_SLIDE_INDEX} currentStep={currentStep}>
|
||||
{isOpenHands ? (
|
||||
<SetupLlmStep onBack={goBack} onNext={goNext} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Loader2, AlertCircle } from "lucide-react";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { getAgentServerFormDefaults } from "#/api/agent-server-config";
|
||||
import { DEFAULT_LOCAL_BACKEND_NAME } from "#/api/backend-registry/default-backend";
|
||||
import { BackendForm } from "#/components/features/backends/backend-form-modal";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
||||
@@ -9,7 +12,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface CheckBackendStepProps {
|
||||
onBack: () => void;
|
||||
onBack?: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
@@ -81,8 +84,23 @@ export function CheckBackendStep({ onBack, onNext }: CheckBackendStepProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { active } = useActiveBackendContext();
|
||||
const { backend } = active;
|
||||
const healthByBackendId = useBackendsHealth([backend]);
|
||||
const isConnected = healthByBackendId[backend.id]?.isConnected ?? null;
|
||||
const noBackendSelected = isNoBackend(backend);
|
||||
const defaults = React.useMemo(() => getAgentServerFormDefaults(), []);
|
||||
const backendForForm = noBackendSelected
|
||||
? {
|
||||
id: "onboarding-local-backend-draft",
|
||||
name: DEFAULT_LOCAL_BACKEND_NAME,
|
||||
host: defaults.baseUrl,
|
||||
apiKey: defaults.sessionApiKey,
|
||||
kind: "local" as const,
|
||||
}
|
||||
: backend;
|
||||
const healthByBackendId = useBackendsHealth(
|
||||
noBackendSelected ? [] : [backend],
|
||||
);
|
||||
const isConnected = noBackendSelected
|
||||
? false
|
||||
: (healthByBackendId[backend.id]?.isConnected ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -101,20 +119,23 @@ export function CheckBackendStep({ onBack, onNext }: CheckBackendStepProps) {
|
||||
<ConnectionBanner isConnected={isConnected} />
|
||||
|
||||
<BackendForm
|
||||
mode="edit"
|
||||
backend={backend}
|
||||
mode={noBackendSelected ? "add" : "edit"}
|
||||
backend={backendForForm}
|
||||
onSubmitted={() => {}}
|
||||
testIdRoot="onboarding-backend"
|
||||
requireApiKey
|
||||
renderActions={({ canSubmit, testIdRoot }) => (
|
||||
<div className="sticky bottom-0 flex items-center justify-end gap-2 mt-2 bg-base-secondary pt-4 pb-7">
|
||||
<BrandButton
|
||||
testId="onboarding-backend-back"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
{t(I18nKey.ONBOARDING$BACK)}
|
||||
</BrandButton>
|
||||
{onBack ? (
|
||||
<BrandButton
|
||||
testId="onboarding-backend-back"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
{t(I18nKey.ONBOARDING$BACK)}
|
||||
</BrandButton>
|
||||
) : null}
|
||||
<BrandButton
|
||||
testId={`${testIdRoot}-submit`}
|
||||
type="submit"
|
||||
|
||||
@@ -91,12 +91,14 @@ function getAgentOptions(): AgentOption[] {
|
||||
interface ChooseAgentStepProps {
|
||||
selectedAgentId: OnboardingAgentId;
|
||||
onSelect: (agentId: OnboardingAgentId) => void;
|
||||
onBack?: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function ChooseAgentStep({
|
||||
selectedAgentId,
|
||||
onSelect,
|
||||
onBack,
|
||||
onNext,
|
||||
}: ChooseAgentStepProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
@@ -190,7 +192,18 @@ export function ChooseAgentStep({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 flex justify-end bg-base-secondary pt-4 pb-7">
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-2 bg-base-secondary pt-4 pb-7">
|
||||
{onBack ? (
|
||||
<BrandButton
|
||||
testId="onboarding-agent-back"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
isDisabled={isSaving}
|
||||
>
|
||||
{t(I18nKey.ONBOARDING$BACK)}
|
||||
</BrandButton>
|
||||
) : null}
|
||||
<BrandButton
|
||||
testId="onboarding-agent-next"
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getAgentServerFormDefaults,
|
||||
saveAgentServerConfig,
|
||||
} from "#/api/agent-server-config";
|
||||
import { getAgentServerFormDefaults } from "#/api/agent-server-config";
|
||||
import {
|
||||
getRegisteredBackends,
|
||||
setRegisteredBackends,
|
||||
@@ -83,15 +80,6 @@ export function AgentServerConnectionForm({
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Persist to the legacy config so the next-session seed and any
|
||||
// module-level fallbacks pick up the new values …
|
||||
saveAgentServerConfig({
|
||||
baseUrl,
|
||||
sessionApiKey,
|
||||
});
|
||||
|
||||
// … and propagate the change into the registry so the active-store
|
||||
// snapshot reflects the new host/api key on this session too.
|
||||
syncDefaultBackendInRegistry();
|
||||
|
||||
reconnect();
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
import { BackNavButton } from "#/components/shared/buttons/back-nav-button";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { useSettingsSectionHeader } from "#/contexts/settings-section-header-context";
|
||||
import {
|
||||
OPENHANDS_LLM_PROXY_BASE_URL,
|
||||
isOpenHandsProviderModel,
|
||||
} from "#/utils/openhands-llm";
|
||||
|
||||
type ViewMode = "list" | "create" | "edit";
|
||||
|
||||
@@ -249,13 +253,17 @@ export function LlmSettingsLocalView() {
|
||||
: {};
|
||||
const llmConfig: Record<string, unknown> = { ...baseConfig, ...dirtyLlm };
|
||||
|
||||
// The Basic tab has no base_url field; the provider implies it. Drop any
|
||||
// (possibly stale, non-proxy) base_url so the backend derives the correct
|
||||
// one — e.g. the All-Hands proxy for openhands/* models, which is required
|
||||
// for the provider to round-trip back to "OpenHands" on reload. Mirrors
|
||||
// LlmSettingsScreen.buildPayload's Basic-view reset.
|
||||
// The Basic tab has no base_url field; the provider implies it. Persist
|
||||
// the All-Hands proxy explicitly for OpenHands models because older local
|
||||
// agent-server builds do not infer the LiteLLM proxy api_base on their own.
|
||||
// For other providers, drop any stale custom value and let the backend use
|
||||
// its normal provider defaults.
|
||||
if (saveControl.view === "basic") {
|
||||
delete llmConfig.base_url;
|
||||
if (isOpenHandsProviderModel(llmConfig.model)) {
|
||||
llmConfig.base_url = OPENHANDS_LLM_PROXY_BASE_URL;
|
||||
} else {
|
||||
delete llmConfig.base_url;
|
||||
}
|
||||
}
|
||||
|
||||
// API key handling: an empty value means "no change" (the UX doesn't
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getActiveSelection,
|
||||
getRegisteredBackends,
|
||||
getSnapshot,
|
||||
NO_BACKEND,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
subscribeActiveBackend,
|
||||
@@ -165,5 +166,5 @@ export function useActiveBackendContext(): ActiveBackendContextValue {
|
||||
export function useActiveBackend(): ResolvedActiveBackend {
|
||||
const ctx = React.useContext(ActiveBackendContext);
|
||||
if (ctx) return ctx.active;
|
||||
return { backend: makeDefaultLocalBackend(), orgId: null };
|
||||
return { backend: makeDefaultLocalBackend() ?? NO_BACKEND, orgId: null };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { SettingsSchema } from "#/types/settings";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
@@ -8,8 +10,18 @@ const useSettingsSchema = (
|
||||
fallbackSchema?: SettingsSchema | null,
|
||||
) => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { backend, orgId } = useActiveBackend();
|
||||
const hasBackend = !isNoBackend(backend);
|
||||
const { data, error, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["settings-schema", type],
|
||||
queryKey: [
|
||||
"settings-schema",
|
||||
type,
|
||||
backend.id,
|
||||
orgId,
|
||||
backend.kind,
|
||||
backend.host,
|
||||
backend.apiKey,
|
||||
],
|
||||
queryFn:
|
||||
type === "conversation"
|
||||
? SettingsService.getConversationSettingsSchema
|
||||
@@ -18,7 +30,7 @@ const useSettingsSchema = (
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 15,
|
||||
enabled: !fallbackSchema && !!userIsAuthenticated,
|
||||
enabled: !fallbackSchema && !!userIsAuthenticated && hasBackend,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { ServerClient } from "@openhands/typescript-client/clients";
|
||||
import { SettingsClient } from "@openhands/typescript-client/clients";
|
||||
import { getCurrentCloudApiKey } from "#/api/cloud/organization-service.api";
|
||||
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
||||
import {
|
||||
@@ -14,14 +15,20 @@ import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
const PROBE_TIMEOUT_MS = 4000;
|
||||
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";
|
||||
|
||||
export function isInvalidBackendApiKeyHealthError(
|
||||
error: string | null | undefined,
|
||||
): boolean {
|
||||
return error === INVALID_BACKEND_API_KEY_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single backend for connectivity. The probe path differs by
|
||||
* backend kind:
|
||||
*
|
||||
* - Local agent-server: GET `/server_info` via the typescript-client.
|
||||
* That's the same endpoint the root compatibility check uses, so a
|
||||
* healthy backend always answers it.
|
||||
* - Local agent-server: GET `/api/settings` via the typescript-client.
|
||||
* Unlike `/server_info`, this validates the configured session API key.
|
||||
* - Cloud: GET `/api/keys/current` via the bundled local
|
||||
* agent-server's `/api/cloud-proxy`. That endpoint is lightweight,
|
||||
* requires auth, and `getCurrentCloudApiKey` already absorbs the
|
||||
@@ -38,13 +45,20 @@ async function probeBackend(backend: Backend): Promise<true> {
|
||||
return true;
|
||||
}
|
||||
|
||||
await new ServerClient(
|
||||
getAgentServerClientOptions({
|
||||
host: backend.host,
|
||||
sessionApiKey: backend.apiKey || null,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
}),
|
||||
).getServerInfo();
|
||||
try {
|
||||
await new SettingsClient(
|
||||
getAgentServerClientOptions({
|
||||
host: backend.host,
|
||||
sessionApiKey: backend.apiKey || null,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
}),
|
||||
).getSettings();
|
||||
} catch (error) {
|
||||
if (isSdkHttpStatusError(error, 401)) {
|
||||
throw new Error(INVALID_BACKEND_API_KEY_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { AppConversationPage } from "#/api/conversation-service/agent-server-conversation-service.types";
|
||||
|
||||
export const usePaginatedConversations = (limit: number = 20) => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const active = useActiveBackend();
|
||||
const hasBackend = !isNoBackend(active.backend);
|
||||
|
||||
return useInfiniteQuery({
|
||||
// Include the active backend identity so each (backend, org) pair
|
||||
@@ -29,7 +31,7 @@ export const usePaginatedConversations = (limit: number = 20) => {
|
||||
|
||||
return result;
|
||||
},
|
||||
enabled: !!userIsAuthenticated,
|
||||
enabled: !!userIsAuthenticated && hasBackend,
|
||||
getNextPageParam: (lastPage: AppConversationPage) => lastPage.next_page_id,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
// Poll every 10s so titles, execution status, and timestamps stay fresh
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from "axios";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings, SettingsScope, SettingsValue } from "#/types/settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { isNoBackend } from "#/api/backend-registry/active-store";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
import {
|
||||
@@ -128,6 +129,7 @@ export const getSettingsQueryFn = async (
|
||||
|
||||
export const useSettings = (scope: SettingsScope = "personal") => {
|
||||
const active = useActiveBackend();
|
||||
const hasBackend = !isNoBackend(active.backend);
|
||||
const query = useQuery({
|
||||
// Include the active backend identity so switching backends or orgs
|
||||
// produces a fresh query — the `staleTime` cache for one backend
|
||||
@@ -139,6 +141,7 @@ export const useSettings = (scope: SettingsScope = "personal") => {
|
||||
],
|
||||
queryFn: () => getSettingsQueryFn(scope),
|
||||
retry: (_, error) => getErrorStatus(error) !== 404,
|
||||
enabled: hasBackend,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 15,
|
||||
@@ -147,6 +150,21 @@ export const useSettings = (scope: SettingsScope = "personal") => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasBackend) {
|
||||
return {
|
||||
data: DEFAULT_SETTINGS,
|
||||
error: null,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetched: false,
|
||||
isSuccess: true,
|
||||
status: "success" as const,
|
||||
fetchStatus: "idle" as const,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
if (getErrorStatus(query.error) === 404) {
|
||||
return {
|
||||
data: DEFAULT_SETTINGS,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
|
||||
import { isSettingsPageHidden } from "#/utils/settings-utils";
|
||||
|
||||
interface LlmConfiguredResult {
|
||||
@@ -37,9 +38,18 @@ export function useLlmConfigured(): LlmConfiguredResult {
|
||||
isLoading: configLoading,
|
||||
isError: configError,
|
||||
} = useConfig();
|
||||
const {
|
||||
data: profilesData,
|
||||
isLoading: profilesLoading,
|
||||
isError: profilesError,
|
||||
} = useLlmProfiles();
|
||||
|
||||
const isAcpAgent = settings?.agent_settings?.agent_kind === "acp";
|
||||
const hasApiKey = settings?.llm_api_key_set === true;
|
||||
const activeProfile = profilesData?.profiles.find(
|
||||
(profile) => profile.name === profilesData.active_profile,
|
||||
);
|
||||
const hasActiveProfileApiKey = activeProfile?.api_key_set === true;
|
||||
const llmSettingsHidden = isSettingsPageHidden(
|
||||
"/settings/llm",
|
||||
config?.feature_flags,
|
||||
@@ -54,9 +64,13 @@ export function useLlmConfigured(): LlmConfiguredResult {
|
||||
// state the banner exists to catch — so we keep deciding from that data.
|
||||
const settingsIndeterminate = settingsLoading || (settingsError && !settings);
|
||||
const configIndeterminate = configLoading || (configError && !config);
|
||||
const profilesIndeterminate =
|
||||
profilesLoading || (profilesError && !profilesData);
|
||||
|
||||
return {
|
||||
isConfigured: isAcpAgent || hasApiKey || llmSettingsHidden,
|
||||
isLoading: settingsIndeterminate || configIndeterminate,
|
||||
isConfigured:
|
||||
isAcpAgent || hasApiKey || hasActiveProfileApiKey || llmSettingsHidden,
|
||||
isLoading:
|
||||
settingsIndeterminate || configIndeterminate || profilesIndeterminate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29425,5 +29425,22 @@
|
||||
"de": "Gib eine gültige URL ein (z. B. http://localhost:8080)",
|
||||
"uk": "Введіть дійсну URL-адресу (наприклад, http://localhost:8080)",
|
||||
"ca": "Introduïu un URL vàlid (p. ex. http://localhost:8080)"
|
||||
},
|
||||
"BACKEND$NO_BACKEND_AVAILABLE": {
|
||||
"ar": "No Backend Available",
|
||||
"ca": "No Backend Available",
|
||||
"de": "No Backend Available",
|
||||
"en": "No Backend Available",
|
||||
"es": "No Backend Available",
|
||||
"fr": "No Backend Available",
|
||||
"it": "No Backend Available",
|
||||
"ja": "No Backend Available",
|
||||
"ko-KR": "No Backend Available",
|
||||
"no": "No Backend Available",
|
||||
"pt": "No Backend Available",
|
||||
"tr": "No Backend Available",
|
||||
"uk": "No Backend Available",
|
||||
"zh-CN": "No Backend Available",
|
||||
"zh-TW": "No Backend Available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
type SettingsView,
|
||||
} from "#/utils/sdk-settings-schema";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import {
|
||||
OPENHANDS_LLM_PROXY_BASE_URL,
|
||||
isOpenHandsProviderModel,
|
||||
} from "#/utils/openhands-llm";
|
||||
|
||||
const LLM_EXCLUDED_KEYS = new Set(["llm.model", "llm.api_key", "llm.base_url"]);
|
||||
|
||||
@@ -41,11 +45,11 @@ const getSchemaFieldDefaultValue = (
|
||||
const KNOWN_PROVIDER_DEFAULT_BASE_URLS: Partial<Record<string, Set<string>>> = {
|
||||
openai: new Set(["https://api.openai.com", "https://api.openai.com/v1"]),
|
||||
openhands: new Set([
|
||||
"https://llm-proxy.app.all-hands.dev",
|
||||
OPENHANDS_LLM_PROXY_BASE_URL,
|
||||
"https://llm-proxy.app.all-hands.dev/v1",
|
||||
]),
|
||||
litellm_proxy: new Set([
|
||||
"https://llm-proxy.app.all-hands.dev",
|
||||
OPENHANDS_LLM_PROXY_BASE_URL,
|
||||
"https://llm-proxy.app.all-hands.dev/v1",
|
||||
]),
|
||||
};
|
||||
@@ -278,7 +282,10 @@ export function LlmSettingsScreen({
|
||||
const llm = (agentSettings.llm ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (context.view === "basic") {
|
||||
llm.base_url = getSchemaFieldDefaultValue(schema, "llm.base_url");
|
||||
const model = llm.model ?? context.values["llm.model"];
|
||||
llm.base_url = isOpenHandsProviderModel(model)
|
||||
? OPENHANDS_LLM_PROXY_BASE_URL
|
||||
: getSchemaFieldDefaultValue(schema, "llm.base_url");
|
||||
agentSettings.llm = llm;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const OPENHANDS_LLM_PROXY_BASE_URL =
|
||||
"https://llm-proxy.app.all-hands.dev/";
|
||||
|
||||
export function isOpenHandsProviderModel(model: unknown): model is string {
|
||||
return typeof model === "string" && model.startsWith("openhands/");
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
* 2. Conversation + switch: start a conversation from the home page,
|
||||
* wait for the agent to reply, then type `/model <profile-B>` in the
|
||||
* chat input. Verify the "Switched to profile" confirmation renders in
|
||||
* the chat UI. Verify the switch_profile POST was made to the agent-server.
|
||||
* the chat UI. Verify the switch_llm POST was made to the agent-server
|
||||
* (the frontend fetches the full encrypted profile config and sends it
|
||||
* via /switch_llm rather than calling /switch_profile by name).
|
||||
*
|
||||
* 3. Post-switch verification: send another message after the switch
|
||||
* and verify the agent responds, proving the conversation continues
|
||||
@@ -174,19 +176,21 @@ test.describe("mock-LLM /model slash command", () => {
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Track whether the switch_profile POST was intercepted.
|
||||
let switchProfileCalled = false;
|
||||
let switchProfileBody: Record<string, unknown> | null = null;
|
||||
// Track whether the switch_llm POST was intercepted.
|
||||
// The frontend calls POST /api/conversations/{id}/switch_llm with the full
|
||||
// encrypted profile config (model + api_key + base_url) rather than calling
|
||||
// /switch_profile by name — this avoids an extra agent-server secrets fetch.
|
||||
let switchLlmCalled = false;
|
||||
let switchLlmBody: Record<string, unknown> | null = null;
|
||||
page.on("request", (req) => {
|
||||
const url = new URL(req.url());
|
||||
// The switch_profile endpoint is POST /api/conversations/{id}/switch_profile
|
||||
if (
|
||||
req.method() === "POST" &&
|
||||
url.pathname.match(/\/api\/conversations\/[^/]+\/switch_profile/)
|
||||
url.pathname.match(/\/api\/conversations\/[^/]+\/switch_llm/)
|
||||
) {
|
||||
switchProfileCalled = true;
|
||||
switchLlmCalled = true;
|
||||
try {
|
||||
switchProfileBody = req.postDataJSON();
|
||||
switchLlmBody = req.postDataJSON();
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
@@ -232,19 +236,22 @@ test.describe("mock-LLM /model slash command", () => {
|
||||
await waitForNonUserMessageText(page, PROFILE_B_NAME, 30_000);
|
||||
});
|
||||
|
||||
// ── Verify: the switch_profile POST was made ──
|
||||
// ── Verify: the switch_llm POST was made ──
|
||||
|
||||
await test.step("verify switch_profile API was called", async () => {
|
||||
await test.step("verify switch_llm API was called with profile B model", async () => {
|
||||
expect(
|
||||
switchProfileCalled,
|
||||
"POST /switch_profile should have been called",
|
||||
switchLlmCalled,
|
||||
"POST /switch_llm should have been called",
|
||||
).toBe(true);
|
||||
expect(switchProfileBody).toBeTruthy();
|
||||
// The switch_profile API uses { profile_name: "..." }
|
||||
expect(switchLlmBody).toBeTruthy();
|
||||
// ConversationClient.switchLLM posts { llm: <config> } to switch_llm,
|
||||
// so the model is nested under the "llm" key in the HTTP body.
|
||||
const llm = switchLlmBody!.llm as Record<string, unknown> | undefined;
|
||||
expect(llm, "switch_llm body should contain an llm object").toBeTruthy();
|
||||
expect(
|
||||
switchProfileBody!.profile_name,
|
||||
`switch_profile body.profile_name should be "${PROFILE_B_NAME}"`,
|
||||
).toBe(PROFILE_B_NAME);
|
||||
llm!.model,
|
||||
`switch_llm body.llm.model should be "${MODEL_B}"`,
|
||||
).toBe(MODEL_B);
|
||||
});
|
||||
|
||||
// ── Send a follow-up message to verify conversation still works ──
|
||||
|
||||
@@ -42,14 +42,33 @@ export const SESSION_API_KEY = (() => {
|
||||
return key;
|
||||
})();
|
||||
|
||||
/** Seed localStorage with flags that skip onboarding / analytics modals. */
|
||||
/** Seed localStorage with flags that skip onboarding / analytics modals
|
||||
* and a default local backend so the app doesn't show the manage-backends
|
||||
* modal. The backend registry must be seeded because the static build has
|
||||
* no baked VITE_SESSION_API_KEY — makeDefaultLocalBackend() returns null
|
||||
* and readLegacyBackend() can't infer a host from the injected config. */
|
||||
export async function seedLocalStorage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("analytics-consent", "false");
|
||||
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||
window.localStorage.setItem("openhands-telemetry-first-use", "true");
|
||||
window.localStorage.setItem("openhands-onboarded", "1");
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ apiKey }) => {
|
||||
window.localStorage.setItem("analytics-consent", "false");
|
||||
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||
window.localStorage.setItem("openhands-telemetry-first-use", "true");
|
||||
window.localStorage.setItem("openhands-onboarded", "1");
|
||||
window.localStorage.setItem(
|
||||
"openhands-backends",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: window.location.origin,
|
||||
apiKey,
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
{ apiKey: SESSION_API_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
/** Inject session API key header into requests targeting the backend. */
|
||||
|
||||
@@ -52,6 +52,12 @@ async function waitForStep(page: Page, step: number) {
|
||||
);
|
||||
}
|
||||
|
||||
async function clickOnboardingStepButton(page: Page, testId: string) {
|
||||
// Snapshot slides are translated and clipped during transitions. In CI,
|
||||
// Playwright can resolve the button as visible but outside the viewport.
|
||||
await page.getByTestId(testId).dispatchEvent("click");
|
||||
}
|
||||
|
||||
test.describe("Onboarding Modal Visual Snapshots", () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
@@ -84,7 +90,7 @@ test.describe("Onboarding Modal Visual Snapshots", () => {
|
||||
await waitForStep(page, 0);
|
||||
|
||||
// Advance to step 1
|
||||
await page.getByTestId("onboarding-agent-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-agent-next");
|
||||
await waitForStep(page, 1);
|
||||
|
||||
// Wait for the backend connection banner to settle.
|
||||
@@ -112,14 +118,14 @@ test.describe("Onboarding Modal Visual Snapshots", () => {
|
||||
await waitForStep(page, 0);
|
||||
|
||||
// Step 0 → 1
|
||||
await page.getByTestId("onboarding-agent-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-agent-next");
|
||||
await waitForStep(page, 1);
|
||||
|
||||
// Wait for backend connected banner then advance
|
||||
await expect(page.getByTestId("onboarding-backend-connected")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("onboarding-backend-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-backend-next");
|
||||
await waitForStep(page, 2);
|
||||
|
||||
// Wait for LLM settings to load (MSW settings + schema endpoints)
|
||||
@@ -143,14 +149,14 @@ test.describe("Onboarding Modal Visual Snapshots", () => {
|
||||
await waitForStep(page, 0);
|
||||
|
||||
// Step 0 → 1
|
||||
await page.getByTestId("onboarding-agent-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-agent-next");
|
||||
await waitForStep(page, 1);
|
||||
|
||||
// Step 1 → 2 (requires backend connected)
|
||||
await expect(page.getByTestId("onboarding-backend-connected")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("onboarding-backend-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-backend-next");
|
||||
await waitForStep(page, 2);
|
||||
|
||||
// Allow LLM settings to finish loading so the save control is registered
|
||||
@@ -160,7 +166,7 @@ test.describe("Onboarding Modal Visual Snapshots", () => {
|
||||
// If the LLM form is dirty (it is, because ONBOARDING_LLM_OVERRIDES differs
|
||||
// from the mock default model), clicking Next will trigger a PATCH settings
|
||||
// mutation. MSW handles the PATCH and resolves onSaveSuccess → onNext.
|
||||
await page.getByTestId("onboarding-llm-next").click();
|
||||
await clickOnboardingStepButton(page, "onboarding-llm-next");
|
||||
await waitForStep(page, 3);
|
||||
|
||||
// Wait for the say-hello input to be ready
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface SeedLocalStorageOptions {
|
||||
* Defaults (overridable via options):
|
||||
* - openhands-onboarded = "1" (suppresses onboarding modal)
|
||||
* - openhands-telemetry-consent = "denied" (suppresses analytics consent modal)
|
||||
* - openhands-backends / openhands-active-backend = seeded local backend
|
||||
*/
|
||||
export async function seedLocalStorage(
|
||||
page: Page,
|
||||
@@ -51,6 +52,26 @@ export async function seedLocalStorage(
|
||||
if (!showConsentModal) {
|
||||
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||
}
|
||||
if (!window.localStorage.getItem("openhands-backends")) {
|
||||
window.localStorage.setItem(
|
||||
"openhands-backends",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "default-local",
|
||||
name: "Local",
|
||||
host: window.location.origin,
|
||||
apiKey: "test-session-key",
|
||||
kind: "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (!window.localStorage.getItem("openhands-active-backend")) {
|
||||
window.localStorage.setItem(
|
||||
"openhands-active-backend",
|
||||
JSON.stringify({ backendId: "default-local", orgId: null }),
|
||||
);
|
||||
}
|
||||
for (const [key, value] of extra) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
+27
-6
@@ -1,8 +1,13 @@
|
||||
import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { server } from "#/mocks/node";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Some modules read env at import time before Vitest's per-test hooks run.
|
||||
// The beforeEach below restores the same default after tests call
|
||||
// `vi.unstubAllEnvs()`.
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key");
|
||||
|
||||
if (typeof HTMLCanvasElement !== "undefined") {
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn();
|
||||
}
|
||||
@@ -22,14 +27,25 @@ windowStub.scrollTo = vi.fn();
|
||||
// Node.js 25+ ships a built-in localStorage that requires --localstorage-file
|
||||
// and is not functional without it. Stub it with a plain in-memory
|
||||
// implementation so zustand's persist middleware works in tests.
|
||||
if (typeof localStorage === "undefined" || typeof localStorage.setItem !== "function") {
|
||||
if (
|
||||
typeof localStorage === "undefined" ||
|
||||
typeof localStorage.setItem !== "function"
|
||||
) {
|
||||
const store: Record<string, string> = {};
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = String(value); },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { Object.keys(store).forEach((k) => delete store[k]); },
|
||||
get length() { return Object.keys(store).length; },
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(store).forEach((k) => delete store[k]);
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
});
|
||||
}
|
||||
@@ -89,6 +105,11 @@ beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "bypass" });
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.resetHandlers();
|
||||
// Cleanup the document body after each test
|
||||
|
||||
Reference in New Issue
Block a user