mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:58:33 +08:00
* 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>
98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
import axios from "axios";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
__resetActiveStoreForTests,
|
|
setActiveSelection,
|
|
setRegisteredBackends,
|
|
} from "#/api/backend-registry/active-store";
|
|
import { callCloudProxy } from "#/api/cloud/proxy";
|
|
import type { Backend } from "#/api/backend-registry/types";
|
|
|
|
vi.mock("axios");
|
|
|
|
const cloudPersonal: Backend = {
|
|
id: "cloud-personal",
|
|
name: "Production - Personal",
|
|
host: "https://app.all-hands.dev",
|
|
apiKey: "personal-key",
|
|
kind: "cloud",
|
|
};
|
|
|
|
const cloudAcme: Backend = {
|
|
id: "cloud-acme",
|
|
name: "Production - Acme",
|
|
host: "https://app.all-hands.dev",
|
|
apiKey: "acme-key",
|
|
kind: "cloud",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
__resetActiveStoreForTests();
|
|
vi.mocked(axios.request).mockReset();
|
|
vi.mocked(axios.request).mockResolvedValue({ data: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear();
|
|
__resetActiveStoreForTests();
|
|
vi.mocked(axios.request).mockReset();
|
|
});
|
|
|
|
describe("callCloudProxy X-Org-Id injection", () => {
|
|
it("sends X-Org-Id when targeting the active cloud backend with a selected orgId", async () => {
|
|
// 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([cloudPersonal]);
|
|
setActiveSelection({
|
|
backendId: cloudPersonal.id,
|
|
orgId: "org-personal-uuid",
|
|
});
|
|
|
|
// Act
|
|
await callCloudProxy({
|
|
backend: cloudPersonal,
|
|
method: "GET",
|
|
path: "/api/v1/app-conversations/search",
|
|
});
|
|
|
|
// 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(
|
|
(config as { headers: Record<string, string> }).headers["X-Org-Id"],
|
|
).toBe("org-personal-uuid");
|
|
});
|
|
|
|
it("omits X-Org-Id when targeting a different cloud backend than the active one", async () => {
|
|
// Arrange — the BackendSelector fan-out (e.g. useAllCloudOrganizations)
|
|
// calls callCloudProxy(b) for every registered cloud backend. Sending
|
|
// the active backend's orgId across an unrelated API key would cause
|
|
// the cloud backend to 403 on api_key_org_id / X-Org-Id mismatch.
|
|
setRegisteredBackends([cloudPersonal, cloudAcme]);
|
|
setActiveSelection({
|
|
backendId: cloudPersonal.id,
|
|
orgId: "org-personal-uuid",
|
|
});
|
|
|
|
// Act — request targets the non-active backend.
|
|
await callCloudProxy({
|
|
backend: cloudAcme,
|
|
method: "GET",
|
|
path: "/api/keys/current",
|
|
});
|
|
|
|
// Assert
|
|
const [config] = vi.mocked(axios.request).mock.calls[0]!;
|
|
expect(
|
|
(config as { headers: Record<string, string> }).headers,
|
|
).not.toHaveProperty("X-Org-Id");
|
|
});
|
|
});
|