feat: cloud backend support with multi-backend selector and SaaS proxy routing (#145)

* feat: multi-backend support with cloud SaaS proxy routing

* feat: route conversation export through cloud proxy on cloud backends

* fix: route conversation delete through cloud proxy on cloud backends

* fix: forward settings diffs verbatim through cloud proxy save

* fix: surface cloud-aware settings sub-pages and gate local-only routes

* fix: route secrets settings through cloud proxy on cloud backends

* fix: route conversation stop runtime through cloud proxy on cloud backends

* fix: re-expose planning agent UI for cloud backends and route plan file reads through cloud proxy

* fix: route Display Cost runtime fetch through cloud proxy and ungate local metrics without session API key

* fix: handle WAITING_FOR_SANDBOX task status from cloud backends to prevent UI crash

* fix: re-expose Public Share in conversation menu for cloud backends

* fix: redirect to home when switching backends from a conversation page

* fix: hide cloud orgs the API key can't access in backend selector

* feat: support running multiple local agent-servers with shared persistence

* fix: lint

* fix: failing tests
This commit is contained in:
Hiep Le
2026-05-08 01:17:40 +07:00
committed by GitHub
parent ee0359b0f3
commit dd744b13f6
139 changed files with 7295 additions and 328 deletions
+3 -2
View File
@@ -24,6 +24,7 @@ VITE_MOCK_API="false" # Enable/disable API mocking with MSW
# OH_CANVAS_SAFE_VSCODE_PORT="18001" # Port forwarded to the embedded VS Code (defaults to backend port + 1)
# OH_CANVAS_SAFE_STATE_DIR="$HOME/.openhands/agent-canvas" # Where conversations, tmux sockets, and bash events are stored
# Agent server version selection (uvx)
# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset.
# Agent server version selection (uvx) — listed in order of precedence (highest first)
# OH_AGENT_SERVER_LOCAL_PATH="" # Absolute path to a local software-agent-sdk checkout. Runs the local checkout via uvx with editable openhands-sdk/tools/workspace installs so source edits are picked up on restart. Highest precedence.
# OH_AGENT_SERVER_GIT_REF="" # Git commit SHA or branch name (e.g., "main", "abc1234"). Takes precedence over version.
# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset.
+3 -2
View File
@@ -82,9 +82,10 @@
- README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real `openhands-agent-server` (clone, install uv, optional `.env`, run `npm run dev`).
- Keep README user-focused and move contributor/developer-specific workflows (`dev:safe`, mock mode, detailed env vars/build-test notes) into `DEVELOPMENT.md`.
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables:
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.18.0")
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
- `OH_AGENT_SERVER_LOCAL_PATH` — absolute path to a local `software-agent-sdk` checkout. Runs the local checkout via `uvx` with `--with-editable` for `openhands-sdk`/`openhands-tools`/`openhands-workspace` and `--reinstall` for `openhands-agent-server`, so SDK edits are picked up on restart. Highest precedence.
- `OH_AGENT_SERVER_GIT_REF` — git commit SHA or branch name (takes precedence over version)
- `OH_AGENT_SERVER_VERSION` — specific PyPI version (e.g., "1.18.0")
- `OH_SECRET_KEY` — secret key for settings encryption; uses a default value for local dev, override for production
- Default: latest released version from PyPI
- `scripts/dev-safe.mjs` should fail fast if `uvx` cannot be spawned (for example missing PATH entries).
+18 -3
View File
@@ -16,17 +16,24 @@ It uses `uvx` to run a temporary `agent-server` installation for this checkout o
### Agent server version selection
By default, the latest released version from PyPI is used. You can override this:
By default, the latest released version from PyPI is used. You can override this (highest precedence first):
```sh
# Use a specific PyPI version
OH_AGENT_SERVER_VERSION=1.18.0 npm run dev
# Run against a local software-agent-sdk checkout. Workspace packages
# (openhands-sdk, openhands-tools, openhands-workspace) are installed editable
# so source edits there are picked up on restart. Highest precedence.
OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk npm run dev
# Use a git branch or commit (takes precedence over version)
OH_AGENT_SERVER_GIT_REF=main npm run dev
OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev
# Use a specific PyPI version
OH_AGENT_SERVER_VERSION=1.18.0 npm run dev
```
`OH_AGENT_SERVER_LOCAL_PATH` must be an absolute path to a `software-agent-sdk` checkout containing the `openhands-agent-server`, `openhands-sdk`, `openhands-tools`, and `openhands-workspace` workspace packages. The agent-server itself is rebuilt from local source on each `npm run dev` start (`uvx --reinstall`); the other workspace packages are installed editable, so their source changes take effect without a rebuild.
### Other useful overrides
- `OH_CANVAS_SAFE_BACKEND_PORT` — backend port for the isolated server (default `18000`)
@@ -36,6 +43,14 @@ OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev
## Alternative development workflows
### Multiple local backends (shared persistence)
To run a second standalone agent-server alongside `npm run dev` while sharing
its conversation history and encrypted secrets, see
[docs/multi-backend-setup.md](./docs/multi-backend-setup.md). The
`npm run dev:extra-backend` helper launches an extra server on `:18002` that
reuses the bundled instance's state dir.
### Frontend against an existing backend
Use this only if you intentionally started `agent-server` yourself or want the frontend to talk to another backend:
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ensureCompatibleAgentServer } from "#/api/agent-server-compatibility";
const getServerInfoMock = vi.fn();
const createServerClientMock = vi.fn(() => ({
getServerInfo: getServerInfoMock,
}));
vi.mock("#/api/typescript-client", async () => {
const actual = await vi.importActual<
typeof import("#/api/typescript-client")
>("#/api/typescript-client");
return {
...actual,
createServerClient: (...args: unknown[]) =>
createServerClientMock(...(args as [])),
};
});
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
getServerInfoMock.mockReset();
createServerClientMock.mockClear();
// Return a future-compatible fake version so the check passes.
getServerInfoMock.mockResolvedValue({ version: "99.0.0" });
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("ensureCompatibleAgentServer", () => {
it("targets the bundled local backend even when the active backend is cloud", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
await ensureCompatibleAgentServer();
expect(createServerClientMock).toHaveBeenCalledOnce();
const callArgs = createServerClientMock.mock.calls[0] as unknown as [
{ host?: string; sessionApiKey?: string | null },
];
const overrides = callArgs[0];
// Must NOT use the cloud host — that endpoint doesn't exist on SaaS
// 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");
});
});
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
getActiveBackend,
setActiveSelection,
setRegisteredBackends,
subscribeActiveBackend,
} from "#/api/backend-registry/active-store";
import { BUNDLED_BACKEND_ID } from "#/api/backend-registry/types";
import type { Backend } from "#/api/backend-registry/types";
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
vi.unstubAllEnvs();
__resetActiveStoreForTests();
});
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
};
describe("active-store", () => {
it("falls back to the bundled backend when nothing is selected", () => {
const { backend, orgId } = getActiveBackend();
expect(backend.id).toBe(BUNDLED_BACKEND_ID);
expect(backend.kind).toBe("local");
expect(orgId).toBeNull();
});
it("returns the registered backend matching the active selection", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
const { backend, orgId } = getActiveBackend();
expect(backend).toEqual(cloudBackend);
expect(orgId).toBe("org-2");
});
it("falls back to bundled when the active backend was removed", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: null });
setRegisteredBackends([]);
expect(getActiveBackend().backend.id).toBe(BUNDLED_BACKEND_ID);
});
it("notifies subscribers when selection changes", () => {
const listener = vi.fn();
const unsubscribe = subscribeActiveBackend(listener);
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
expect(listener).toHaveBeenCalled();
unsubscribe();
listener.mockClear();
setActiveSelection(null);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ACTIVE_BACKEND_STORAGE_KEY,
BACKENDS_STORAGE_KEY,
readStoredActiveBackend,
readStoredBackends,
writeStoredActiveBackend,
writeStoredBackends,
} from "#/api/backend-registry/storage";
import type { Backend } from "#/api/backend-registry/types";
afterEach(() => {
window.localStorage.clear();
});
describe("backend-registry storage", () => {
it("round-trips a list of backends", () => {
const backends: Backend[] = [
{
id: "abc",
name: "Local 1",
host: "http://127.0.0.1:9000",
apiKey: "key-1",
kind: "local",
},
{
id: "xyz",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-2",
kind: "cloud",
},
];
writeStoredBackends(backends);
expect(readStoredBackends()).toEqual(backends);
});
it("returns empty list when storage is malformed", () => {
window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json");
expect(readStoredBackends()).toEqual([]);
});
it("filters out backends with invalid shape", () => {
window.localStorage.setItem(
BACKENDS_STORAGE_KEY,
JSON.stringify([
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
{ id: "missing-kind", name: "x", host: "y", apiKey: "z" },
{ kind: "cloud" },
"not-an-object",
]),
);
expect(readStoredBackends()).toEqual([
{ id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" },
]);
});
it("round-trips active selection with orgId", () => {
writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" });
expect(readStoredActiveBackend()).toEqual({
backendId: "xyz",
orgId: "org-1",
});
});
it("normalizes missing orgId to null", () => {
writeStoredActiveBackend({ backendId: "xyz" });
expect(readStoredActiveBackend()).toEqual({
backendId: "xyz",
orgId: null,
});
});
it("clears storage when active selection is set to null", () => {
writeStoredActiveBackend({ backendId: "xyz", orgId: "o" });
writeStoredActiveBackend(null);
expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull();
expect(readStoredActiveBackend()).toBeNull();
});
it("returns null active selection when storage is malformed", () => {
window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken");
expect(readStoredActiveBackend()).toBeNull();
});
});
@@ -0,0 +1,123 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("V1ConversationService cloud branch", () => {
it("createConversation POSTs the SaaS payload through the proxy and returns a WORKING task", async () => {
vi.mocked(axios.post).mockResolvedValue({
data: {
id: "task-123",
created_by_user_id: null,
status: "WORKING",
detail: null,
app_conversation_id: null,
agent_server_url: null,
request: {},
created_at: "2026-05-06T00:00:00Z",
updated_at: "2026-05-06T00:00:00Z",
},
});
const result = await V1ConversationService.createConversation(
"fix the bug",
"Optional title",
undefined,
{
selected_repository: "user/repo",
selected_branch: "main",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
git_provider: "github" as any,
},
);
expect(axios.post).toHaveBeenCalledOnce();
const [url, body] = vi.mocked(axios.post).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,
method: "POST",
path: "/api/v1/app-conversations",
headers: { Authorization: "Bearer bearer-token" },
});
const proxiedBody = (body as { body: Record<string, unknown> }).body;
// SaaS 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({
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");
// The returned task is the upstream task — WORKING, no app_conversation_id yet.
expect(result.id).toBe("task-123");
expect(result.status).toBe("WORKING");
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({
data: [
{
id: "task-123",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "conv-456",
agent_server_url: "https://runtime-456.app.all-hands.dev",
request: {},
created_at: "2026-05-06T00:00:00Z",
updated_at: "2026-05-06T00:00:00Z",
},
],
});
const result = await V1ConversationService.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,
method: "GET",
path: "/api/v1/app-conversations/start-tasks?ids=task-123",
});
expect(result?.status).toBe("READY");
expect(result?.app_conversation_id).toBe("conv-456");
});
});
@@ -0,0 +1,50 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("V1ConversationService.deleteConversation cloud branch", () => {
it("routes through /api/cloud-proxy to the SaaS DELETE app-conversations endpoint", async () => {
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
await V1ConversationService.deleteConversation("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,
method: "DELETE",
path: "/api/v1/app-conversations/conv-abc",
});
});
});
@@ -0,0 +1,53 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("V1ConversationService.downloadConversation cloud branch", () => {
it("routes through /api/cloud-proxy to the SaaS download endpoint with responseType blob and returns the Blob", async () => {
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
vi.mocked(axios.post).mockResolvedValue({ data: zipBlob });
const result = await V1ConversationService.downloadConversation("conv-abc");
expect(axios.post).toHaveBeenCalledOnce();
const [url, body, config] = vi.mocked(axios.post).mock.calls[0]!;
expect(url).toMatch(/\/api\/cloud-proxy$/);
expect(body).toMatchObject({
host: cloudBackend.host,
method: "GET",
path: "/api/v1/app-conversations/conv-abc/download",
});
expect(config).toMatchObject({ responseType: "blob" });
expect(result).toBe(zipBlob);
});
});
@@ -0,0 +1,90 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import type { V1AppConversation } from "#/api/conversation-service/v1-conversation-service.types";
import { pauseV1Conversation } from "#/hooks/mutation/conversation-mutation-utils";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
const buildConversation = (
overrides: Partial<V1AppConversation> = {},
): V1AppConversation => ({
id: "conv-abc",
created_by_user_id: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
title: "Test",
trigger: null,
pr_number: [],
llm_model: null,
metrics: null,
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
execution_status: V1ExecutionStatus.RUNNING,
conversation_url: null,
session_api_key: null,
sandbox_id: "sandbox-xyz",
sub_conversation_ids: [],
...overrides,
});
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.restoreAllMocks();
});
describe("pauseV1Conversation cloud branch", () => {
it("routes through /api/cloud-proxy to POST the SaaS sandbox pause endpoint", async () => {
vi.spyOn(
V1ConversationService,
"batchGetAppConversations",
).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]);
vi.mocked(axios.post).mockResolvedValue({ data: { success: true } });
await pauseV1Conversation("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,
method: "POST",
path: "/api/v1/sandboxes/sandbox-xyz/pause",
});
});
it("throws and does not call cloud-proxy when the cloud conversation has no sandbox_id", async () => {
vi.spyOn(
V1ConversationService,
"batchGetAppConversations",
).mockResolvedValue([buildConversation({ sandbox_id: null })]);
await expect(pauseV1Conversation("conv-abc")).rejects.toThrow(/sandbox_id/);
expect(axios.post).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,60 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("V1ConversationService.updateConversationPublicFlag", () => {
it("PATCHes /api/v1/app-conversations/{id} via cloud-proxy on a cloud backend", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockResolvedValue({
data: { id: "conv-abc", public: true },
});
await V1ConversationService.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,
method: "PATCH",
path: "/api/v1/app-conversations/conv-abc",
body: { public: true },
});
});
it("rejects without calling the proxy when the active backend is local", async () => {
// Default state after reset is the bundled local backend.
await expect(
V1ConversationService.updateConversationPublicFlag("conv-abc", true),
).rejects.toThrow(/cloud backend/);
expect(axios.post).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
const localBackend: Backend = {
id: "self-hosted",
name: "Self-hosted",
host: "http://192.168.1.99:9999",
apiKey: "local-key",
kind: "local",
};
const runtimeResponse = {
id: "conv-abc",
title: "Test conversation",
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
execution_status: "idle",
metrics: null,
stats: {
usage_to_metrics: {
agent: {
model_name: "test-model",
accumulated_cost: 1.23,
max_budget_per_task: null,
accumulated_token_usage: null,
costs: [],
response_latencies: [],
token_usages: [],
},
},
},
};
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("V1ConversationService.getRuntimeConversation", () => {
describe("cloud mode", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
it("routes through /api/cloud-proxy targeting the conversation runtime host", async () => {
// Arrange
vi.mocked(axios.post).mockResolvedValue({ data: runtimeResponse });
const conversationUrl =
"http://abc123.runtime.all-hands.dev/api/conversations/conv-abc";
// Act
const result = await V1ConversationService.getRuntimeConversation(
"conv-abc",
conversationUrl,
"session-xyz",
);
// Assert
expect(axios.post).toHaveBeenCalledOnce();
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
expect(url).toMatch(/\/api\/cloud-proxy$/);
expect(body).toMatchObject({
host: "http://abc123.runtime.all-hands.dev",
method: "GET",
path: "/api/conversations/conv-abc",
headers: { "X-Session-API-Key": "session-xyz" },
});
expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23);
});
});
describe("local mode", () => {
beforeEach(() => {
__resetActiveStoreForTests();
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
vi.mocked(axios.post).mockReset();
});
it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => {
// Arrange
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify(runtimeResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const conversationUrl =
"http://192.168.1.42:8888/api/conversations/conv-abc";
// Act
const result = await V1ConversationService.getRuntimeConversation(
"conv-abc",
conversationUrl,
"session-xyz",
);
// Assert
expect(axios.post).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledOnce();
const call = fetchMock.mock.calls[0] as unknown as [
RequestInfo,
RequestInit,
];
expect(String(call[0])).toContain("192.168.1.42:8888");
expect(String(call[0])).not.toContain(localBackend.host);
expect(call[1]?.headers).toMatchObject({
"X-Session-API-Key": "session-xyz",
});
expect(result.id).toBe("conv-abc");
});
});
});
@@ -0,0 +1,57 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("cloud organization /me via local proxy", () => {
it("calls /api/organizations/{orgId}/me through the proxy and returns user_id", async () => {
const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
vi.mocked(axios.post).mockResolvedValue({
data: {
org_id: orgId,
user_id: orgId,
email: "hieptl.developer@gmail.com",
role: "owner",
},
});
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,
method: "GET",
path: `/api/organizations/${orgId}/me`,
});
expect(result).toEqual({ orgId, userId: orgId });
});
});
@@ -0,0 +1,121 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getCloudOrganizations,
getCurrentCloudApiKey,
switchCloudOrganization,
} from "#/api/cloud/organization-service.api";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
vi.mocked(axios.post).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({
data: {
items: [{ id: "org-1", name: "Personal" }],
current_org_id: "org-1",
},
});
const result = await getCloudOrganizations(cloudBackend);
expect(axios.post).toHaveBeenCalledOnce();
const [url, body, options] = vi.mocked(axios.post).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,
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",
});
});
it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => {
vi.mocked(axios.post).mockResolvedValue({
data: {
id: "key-1",
name: "k",
org_id: "org-bound",
user_id: "user-1",
auth_type: "bearer",
},
});
const result = await getCurrentCloudApiKey(cloudBackend);
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
expect((body as { path: string }).path).toBe("/api/keys/current");
expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false });
});
it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => {
const error = Object.assign(new Error("Bad Request"), {
response: { status: 400 },
});
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
vi.mocked(axios.post).mockRejectedValueOnce(error);
const result = await getCurrentCloudApiKey(cloudBackend);
expect(result).toEqual({ orgId: null, isLegacyKey: true });
});
it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => {
const error = Object.assign(new Error("Unauthorized"), {
response: { status: 401 },
});
vi.mocked(axios.isAxiosError).mockReturnValueOnce(true);
vi.mocked(axios.post).mockRejectedValueOnce(error);
await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBe(error);
});
it("switchCloudOrganization posts to the org-switch path through the proxy", async () => {
vi.mocked(axios.post).mockResolvedValue({ data: {} });
await switchCloudOrganization("org-2", cloudBackend);
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
expect(body).toMatchObject({
host: cloudBackend.host,
method: "POST",
path: "/api/organizations/org-2/switch",
headers: { Authorization: "Bearer bearer-token" },
});
});
});
+126
View File
@@ -0,0 +1,126 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { SecretsService } from "#/api/secrets-service";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("SecretsService against cloud backend", () => {
it("paginates getSecrets through /api/cloud-proxy and returns the merged list", async () => {
vi.mocked(axios.post)
.mockResolvedValueOnce({
data: {
items: [
{ name: "ALPHA", description: "first" },
{ name: "BETA", description: "second" },
],
next_page_id: "BETA",
},
})
.mockResolvedValueOnce({
data: {
items: [{ name: "GAMMA", description: "third" }],
next_page_id: null,
},
});
const secrets = await SecretsService.getSecrets();
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
expect(firstBody).toMatchObject({
host: cloudBackend.host,
method: "GET",
});
expect((firstBody as { path: string }).path).toMatch(
/^\/api\/v1\/secrets\/search\?/,
);
expect((firstBody as { path: string }).path).not.toContain("page_id=");
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
expect((secondBody as { path: string }).path).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: {} });
await SecretsService.createSecret(
"OPENAI_API_KEY",
"sk-test",
"OpenAI key",
);
const [url, body] = vi.mocked(axios.post).mock.calls[0]!;
expect(url).toMatch(/\/api\/cloud-proxy$/);
expect(body).toMatchObject({
host: cloudBackend.host,
method: "POST",
path: "/api/v1/secrets",
body: {
name: "OPENAI_API_KEY",
value: "sk-test",
description: "OpenAI key",
},
});
});
it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => {
vi.mocked(axios.post).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,
method: "PUT",
path: "/api/v1/secrets/OLD_NAME",
body: { 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: {} });
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,
method: "DELETE",
path: "/api/v1/secrets/token%20with%20space",
});
});
});
@@ -0,0 +1,131 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import {
fetchCloudSettings,
saveCloudSettings,
} from "#/api/cloud/settings-service.api";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("cloud settings via local proxy", () => {
it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => {
vi.mocked(axios.post).mockResolvedValue({
data: {
llm_model: "anthropic/claude-3-5-sonnet",
llm_base_url: "https://api.anthropic.com",
llm_api_key_set: true,
agent: "CodeActAgent",
confirmation_mode: true,
security_analyzer: "llm",
max_iterations: 30,
provider_tokens_set: { github: "***" },
},
});
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,
method: "GET",
path: "/api/v1/settings",
});
// provider_tokens_set must round-trip — it's what drives
// useUserProviders → useAppInstallations → useGitRepositories.
expect(result.provider_tokens_set).toEqual({ github: "***" });
// Top-level cloud fields are preserved as-is.
expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet");
expect(result.llm_api_key_set).toBe(true);
expect(result.agent).toBe("CodeActAgent");
// Nested shape derived for the local-mode settings page.
expect(result.agent_settings?.agent).toBe("CodeActAgent");
expect(result.agent_settings?.llm).toEqual({
model: "anthropic/claude-3-5-sonnet",
base_url: "https://api.anthropic.com",
});
expect(result.conversation_settings?.confirmation_mode).toBe(true);
expect(result.conversation_settings?.security_analyzer).toBe("llm");
expect(result.conversation_settings?.max_iterations).toBe(30);
});
it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => {
vi.mocked(axios.post).mockResolvedValue({ data: {} });
const agentDiff = {
llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" },
agent: "CodeActAgent",
};
const conversationDiff = { max_iterations: 50 };
await saveCloudSettings({
agent_settings_diff: agentDiff,
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,
method: "POST",
path: "/api/v1/settings",
});
const proxiedBody = (body as { body: Record<string, unknown> }).body;
expect(proxiedBody).toEqual({
agent_settings_diff: agentDiff,
conversation_settings_diff: conversationDiff,
});
expect(proxiedBody).not.toHaveProperty("agent_settings");
expect(proxiedBody).not.toHaveProperty("conversation_settings");
});
it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => {
vi.mocked(axios.post).mockResolvedValue({ data: {} });
await saveCloudSettings({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
},
conversation_settings_diff: {},
});
const [, body] = vi.mocked(axios.post).mock.calls[0]!;
const proxiedBody = (body as { body: Record<string, unknown> }).body;
expect(proxiedBody).toEqual({
agent_settings_diff: {
llm: { model: "anthropic/claude-sonnet-4-20250514" },
},
});
});
});
@@ -0,0 +1,79 @@
import axios from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import SkillsService from "#/api/skills-service";
vi.mock("axios");
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
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)
.mockResolvedValueOnce({
data: {
items: [
{ name: "alpha", type: "knowledge", source: "global" },
{
name: "beta",
type: "task",
source: "user",
triggers: ["foo"],
},
],
next_page_id: "beta",
},
})
.mockResolvedValueOnce({
data: {
items: [{ name: "gamma", type: "knowledge", source: "user" }],
next_page_id: null,
},
});
const skills = await SkillsService.getSkills();
expect(vi.mocked(axios.post)).toHaveBeenCalledTimes(2);
const [firstUrl, firstBody] = vi.mocked(axios.post).mock.calls[0]!;
expect(firstUrl).toMatch(/\/api\/cloud-proxy$/);
expect(firstBody).toMatchObject({
host: cloudBackend.host,
method: "GET",
});
expect((firstBody as { path: string }).path).toMatch(
/^\/api\/v1\/skills\/search\?/,
);
expect((firstBody as { path: string }).path).not.toContain("page_id=");
const [, secondBody] = vi.mocked(axios.post).mock.calls[1]!;
expect((secondBody as { path: string }).path).toContain("page_id=beta");
expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]);
expect(skills[1]).toMatchObject({ triggers: ["foo"] });
});
});
@@ -98,9 +98,18 @@ describe("ProviderHandler", () => {
expect(page.next_page_id).toBeNull();
});
it("throws GitProviderAuthError when no providers are configured", async () => {
await expect(ProviderHandler.getUserGitInfo()).rejects.toBeInstanceOf(
GitProviderAuthError,
);
it("returns null when no providers are configured (no throw)", async () => {
// Switching from cloud → local while settings is briefly stale
// would otherwise hit this code path with no local tokens. Throwing
// surfaces as a global error toast — returning null lets the caller
// (`useGitUser`) render an empty user-info state instead.
const result = await ProviderHandler.getUserGitInfo();
expect(result).toBeNull();
});
it("still throws GitProviderAuthError when an explicit provider is requested but missing", async () => {
await expect(
ProviderHandler.getUserGitInfo("github"),
).rejects.toBeInstanceOf(GitProviderAuthError);
});
});
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { openHands } from "#/api/open-hands-axios";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
const localBackend: Backend = {
id: "local-1",
name: "Local 1",
host: "http://localhost:9099",
apiKey: "session-key-local",
kind: "local",
};
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token-cloud",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
async function runInterceptors() {
const { AxiosHeaders } = await import("axios");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config: any = { headers: new AxiosHeaders({}) };
// Walk the registered request handlers; axios stores them as
// { fulfilled, rejected } objects on the manager.
const { handlers } = openHands.interceptors.request as unknown as {
handlers: Array<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fulfilled: (c: any) => any;
} | null>;
};
let next = config;
for (const handler of handlers) {
if (handler && typeof handler.fulfilled === "function") {
// Interceptors are an explicit chain — each input depends on the prior
// output, so awaiting in order is correct here.
// eslint-disable-next-line no-await-in-loop
next = await handler.fulfilled(next);
}
}
return next;
}
describe("openHands axios — active backend interceptor", () => {
it("rewrites baseURL + sends X-Session-API-Key for a local backend", async () => {
setRegisteredBackends([localBackend]);
setActiveSelection({ backendId: localBackend.id });
const config = await runInterceptors();
expect(config.baseURL).toBe(localBackend.host);
expect(config.headers.get("X-Session-API-Key")).toBe(localBackend.apiKey);
expect(config.headers.get("Authorization")).toBeFalsy();
});
it("falls back to the bundled local backend when active is cloud (cloud calls go via callCloudProxy)", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
const config = await runInterceptors();
// Must NOT route default openHands traffic to the cloud host — those
// endpoints don't exist on the SaaS and would CORS-fail. Cloud-only
// calls use callCloudProxy directly and bypass this interceptor.
expect(config.baseURL).not.toBe(cloudBackend.host);
expect(config.baseURL).not.toContain("all-hands.dev");
expect(config.headers.get("Authorization")).toBeFalsy();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import UserService from "#/api/user-service/user-service.api";
const cloudGitUser = vi.fn();
const providerHandlerGetUser = vi.fn();
vi.mock("#/api/cloud/user-service.api", () => ({
getCloudGitUser: () => cloudGitUser(),
}));
vi.mock("#/api/git-providers/provider-handler", () => ({
ProviderHandler: {
getUserGitInfo: () => providerHandlerGetUser(),
},
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
cloudGitUser.mockReset();
providerHandlerGetUser.mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("UserService.getUser branch by backend kind", () => {
it("uses the cloud /api/v1/users/git-info path when active is cloud (no false 'No git provider' toast)", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
cloudGitUser.mockResolvedValue({
id: "u1",
login: "alice",
avatar_url: "",
company: null,
name: null,
email: null,
});
const user = await UserService.getUser();
expect(cloudGitUser).toHaveBeenCalledOnce();
// The local "no git provider" check must NOT run for cloud — it would
// throw on a clean install where the user has not pasted a local PAT.
expect(providerHandlerGetUser).not.toHaveBeenCalled();
expect(user?.login).toBe("alice");
});
it("uses the local ProviderHandler when active is local (preserves the local 'No git provider' check)", async () => {
providerHandlerGetUser.mockResolvedValue({
id: "u2",
login: "bob",
avatar_url: "",
company: null,
name: null,
email: null,
});
await UserService.getUser();
expect(providerHandlerGetUser).toHaveBeenCalledOnce();
expect(cloudGitUser).not.toHaveBeenCalled();
});
});
+143 -2
View File
@@ -1,9 +1,19 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import axios from "axios";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
vi.mock("axios");
const {
mockHttpGet,
mockHttpPost,
mockHttpDelete,
mockFileUpload,
mockCreateHttpClient,
mockCreateRemoteWorkspace,
@@ -12,6 +22,7 @@ const {
} = vi.hoisted(() => ({
mockHttpGet: vi.fn(),
mockHttpPost: vi.fn(),
mockHttpDelete: vi.fn(),
mockFileUpload: vi.fn(),
mockCreateHttpClient: vi.fn(),
mockCreateRemoteWorkspace: vi.fn(),
@@ -48,13 +59,14 @@ describe("V1ConversationService", () => {
vi.clearAllMocks();
mockHttpGet.mockReset();
mockHttpPost.mockReset();
mockHttpDelete.mockReset();
mockFileUpload.mockReset();
mockCreateHttpClient.mockReturnValue({
get: mockHttpGet,
post: mockHttpPost,
patch: vi.fn(),
delete: vi.fn(),
delete: mockHttpDelete,
});
mockCreateRemoteWorkspace.mockReturnValue({
fileUpload: mockFileUpload,
@@ -147,6 +159,135 @@ describe("V1ConversationService", () => {
});
});
describe("downloadConversation local branch", () => {
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => {
const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" });
mockHttpGet.mockResolvedValue({ data: zipBlob });
const result =
await V1ConversationService.downloadConversation("conv-abc");
expect(mockHttpGet).toHaveBeenCalledWith(
"/api/file/download-trajectory/conv-abc",
expect.objectContaining({ responseType: "blob" }),
);
expect(result).toBe(zipBlob);
});
});
describe("deleteConversation local branch", () => {
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("hits the local /api/conversations/{id} endpoint when active backend is local", async () => {
mockHttpDelete.mockResolvedValue({ data: undefined });
await V1ConversationService.deleteConversation("conv-abc");
expect(mockHttpDelete).toHaveBeenCalledWith(
"/api/conversations/conv-abc",
);
});
});
describe("cloud branches", () => {
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
vi.mocked(axios.post).mockReset();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => {
// Arrange
vi.mocked(axios.post).mockResolvedValue({
data: {
id: "task-1",
status: "WORKING",
app_conversation_id: null,
agent_server_url: null,
request: {},
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
});
// Act
await V1ConversationService.createConversation(
undefined,
undefined,
undefined,
null,
undefined,
"parent-conv-1",
"plan",
"sandbox-9",
);
// 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({
parent_conversation_id: "parent-conv-1",
agent_type: "plan",
sandbox_id: "sandbox-9",
});
});
it("routes readConversationFile to the SaaS file endpoint with the file_path query param", async () => {
// Arrange
vi.mocked(axios.post).mockResolvedValue({ data: "# PLAN content" });
// Act
const content =
await V1ConversationService.readConversationFile("conv-cloud-1");
// 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",
);
});
});
describe("uploadFile", () => {
it("uses query params for file upload path", async () => {
const file = new File(["test content"], "test.txt", {
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { AddBackendMenuItem } from "#/components/features/backends/add-backend-menu-item";
describe("AddBackendMenuItem", () => {
it("calls onOpen when clicked and does not render the modal itself", async () => {
const onOpen = vi.fn();
render(<AddBackendMenuItem onOpen={onOpen} />);
const user = userEvent.setup();
await user.click(screen.getByTestId("add-backend-menu-item"));
expect(onOpen).toHaveBeenCalledOnce();
expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,169 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } 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";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { AddBackendModal } from "#/components/features/backends/add-backend-modal";
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>{ui}</ActiveBackendProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("AddBackendModal", () => {
it("renders Save (left) before Cancel (right) in a 2-column grid", () => {
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
const submit = screen.getByTestId("add-backend-submit");
const cancel = screen.getByTestId("add-backend-cancel");
const row = submit.parentElement!;
// Save comes before Cancel in DOM order — Save is on the left.
const orderTest = submit.compareDocumentPosition(cancel);
// Bit 4 = DOCUMENT_POSITION_FOLLOWING: cancel comes after submit.
// eslint-disable-next-line no-bitwise
expect(orderTest & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// Equal-width via grid-cols-2: each column is exactly 50% of the row.
expect(row.className).toContain("grid-cols-2");
});
it("disables submit until all fields are filled", async () => {
const onClose = vi.fn();
renderWithProviders(<AddBackendModal onClose={onClose} />);
const submit = screen.getByTestId(
"add-backend-submit",
) as HTMLButtonElement;
expect(submit).toBeDisabled();
const user = userEvent.setup();
await user.type(screen.getByTestId("add-backend-name"), "Production");
expect(submit).toBeDisabled();
await user.type(
screen.getByTestId("add-backend-host"),
"https://app.all-hands.dev",
);
expect(submit).toBeDisabled();
await user.type(screen.getByTestId("add-backend-api-key"), "secret-key");
expect(submit).not.toBeDisabled();
});
it("infers cloud kind from an all-hands.dev host", async () => {
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
const cloudRadio = screen.getByTestId(
"add-backend-kind-cloud",
) as HTMLInputElement;
const localRadio = screen.getByTestId(
"add-backend-kind-local",
) as HTMLInputElement;
fireEvent.change(screen.getByTestId("add-backend-host"), {
target: { value: "https://app.all-hands.dev" },
});
expect(cloudRadio.checked).toBe(true);
expect(localRadio.checked).toBe(false);
});
it("allows submitting a local backend with a blank API key", async () => {
const onClose = vi.fn();
renderWithProviders(<AddBackendModal onClose={onClose} />);
const user = userEvent.setup();
await user.type(screen.getByTestId("add-backend-name"), "Local Extra");
await user.type(
screen.getByTestId("add-backend-host"),
"http://127.0.0.1:18002",
);
// No API key entered; kind auto-infers to "local" from the host.
await user.click(screen.getByTestId("add-backend-submit"));
const stored = JSON.parse(
window.localStorage.getItem("openhands-backends") ?? "[]",
);
expect(stored).toMatchObject([
{
name: "Local Extra",
host: "http://127.0.0.1:18002",
apiKey: "",
kind: "local",
},
]);
});
it("keeps submit disabled for cloud backends until an API key is entered", async () => {
renderWithProviders(<AddBackendModal onClose={vi.fn()} />);
const submit = screen.getByTestId(
"add-backend-submit",
) as HTMLButtonElement;
const user = userEvent.setup();
await user.type(screen.getByTestId("add-backend-name"), "Cloud");
await user.type(
screen.getByTestId("add-backend-host"),
"https://app.all-hands.dev",
);
expect(submit).toBeDisabled();
await user.type(screen.getByTestId("add-backend-api-key"), "token");
expect(submit).not.toBeDisabled();
});
it("saves the backend and closes WITHOUT switching the active selection", async () => {
const onClose = vi.fn();
renderWithProviders(<AddBackendModal onClose={onClose} />);
const user = userEvent.setup();
await user.type(screen.getByTestId("add-backend-name"), "Local 1");
await user.type(
screen.getByTestId("add-backend-host"),
"http://localhost:9000",
);
await user.type(screen.getByTestId("add-backend-api-key"), "k");
await user.click(screen.getByTestId("add-backend-kind-local"));
await user.click(screen.getByTestId("add-backend-submit"));
expect(onClose).toHaveBeenCalled();
const stored = JSON.parse(
window.localStorage.getItem("openhands-backends") ?? "[]",
);
expect(stored).toHaveLength(1);
expect(stored[0]).toMatchObject({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
// Adding a backend must NOT change the active selection. Auto-switch
// would write `(backendId, null)` for a cloud backend, which the
// dropdown can't render once orgs load — UI/API would drift.
expect(window.localStorage.getItem("openhands-active-backend")).toBeNull();
});
});
@@ -0,0 +1,435 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import {
ActiveBackendProvider,
useActiveBackendContext,
} from "#/contexts/active-backend-context";
import { BackendSelector } from "#/components/features/backends/backend-selector";
import {
getCloudOrganizations,
switchCloudOrganization,
getCloudOrganizationMe,
getCurrentCloudApiKey,
} from "#/api/cloud/organization-service.api";
vi.mock("#/api/cloud/organization-service.api", () => ({
getCloudOrganizations: vi.fn(),
switchCloudOrganization: vi.fn().mockResolvedValue(undefined),
getCloudOrganizationMe: vi.fn(),
getCurrentCloudApiKey: vi.fn(),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>{ui}</ActiveBackendProvider>
</QueryClientProvider>
</MemoryRouter>,
);
}
function TestSeed({
onMount,
children,
}: {
onMount: (ctx: ReturnType<typeof useActiveBackendContext>) => void;
children: React.ReactNode;
}) {
const ctx = useActiveBackendContext();
React.useEffect(() => {
onMount(ctx);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return children as React.ReactElement;
}
async function openDropdown() {
const user = userEvent.setup();
const wrapper = screen.getByTestId("backend-selector");
await user.click(within(wrapper).getByTestId("dropdown-trigger"));
return user;
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(getCloudOrganizations).mockReset();
vi.mocked(switchCloudOrganization).mockReset();
vi.mocked(switchCloudOrganization).mockResolvedValue(undefined);
vi.mocked(getCloudOrganizationMe).mockReset();
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
orgId: "",
userId: "",
});
// Default to the legacy-key fallback so existing assertions about
// multiple orgs being visible still hold. Tests that exercise
// org-filter behavior override this with an explicit orgId.
vi.mocked(getCurrentCloudApiKey).mockReset();
vi.mocked(getCurrentCloudApiKey).mockResolvedValue({
orgId: null,
isLegacyKey: true,
});
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("BackendSelector", () => {
it("uses the bundled Local label by default", () => {
renderWithProviders(<BackendSelector />);
const wrapper = screen.getByTestId("backend-selector");
const input = wrapper.querySelector("input") as HTMLInputElement;
expect(input.value).toBe("BACKEND$LOCAL_ROW");
});
it("lists all registered backends in the dropdown", async () => {
renderWithProviders(
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
ctx.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "b",
kind: "cloud",
});
}}
>
<BackendSelector />
</TestSeed>,
);
await openDropdown();
expect(screen.getByText("BACKEND$LOCAL_ROW")).toBeInTheDocument();
expect(screen.getByText("Local 1")).toBeInTheDocument();
expect(screen.getByText("Production")).toBeInTheDocument();
});
it("expands a cloud backend into one row per org and fires switch-org on select", async () => {
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "org-personal", name: "Personal" },
{ id: "org-2", name: "Acme Inc" },
],
currentOrgId: "org-personal",
});
renderWithProviders(
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
});
}}
>
<BackendSelector />
</TestSeed>,
);
const user = await openDropdown();
await waitFor(() => {
expect(screen.getByText("Production – Personal")).toBeInTheDocument();
});
expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
await user.click(screen.getByText("Production – Acme Inc"));
await waitFor(() => {
expect(switchCloudOrganization).toHaveBeenCalled();
});
// The selector now passes an explicit backend so /switch lands on
// the right cloud BEFORE the active selection flips.
expect(switchCloudOrganization).toHaveBeenCalledWith(
"org-2",
expect.objectContaining({ host: "https://app.all-hands.dev" }),
);
});
it("filters each cloud backend's org rows to the org its API key is bound to", async () => {
// Both backends point at the same host; the user belongs to all three
// orgs, but each API key is scoped to a different one. The selector
// must show one row per backend, each labeled with its key's own org.
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: "org-personal", name: "Personal" },
{ id: "org-acme", name: "Acme Inc" },
{ id: "org-beta", name: "Beta Co" },
],
currentOrgId: "org-personal",
});
vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({
orgId: backend?.apiKey === "key-personal" ? "org-personal" : "org-acme",
isLegacyKey: false,
}));
renderWithProviders(
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "ProdPersonal",
host: "https://app.all-hands.dev",
apiKey: "key-personal",
kind: "cloud",
});
ctx.addBackend({
name: "ProdAcme",
host: "https://app.all-hands.dev",
apiKey: "key-acme",
kind: "cloud",
});
}}
>
<BackendSelector />
</TestSeed>,
);
await openDropdown();
await waitFor(() => {
expect(
screen.getByText("ProdPersonal – Personal"),
).toBeInTheDocument();
});
expect(screen.getByText("ProdAcme – Acme Inc")).toBeInTheDocument();
// Inaccessible orgs must not appear under either backend.
expect(
screen.queryByText("ProdPersonal – Acme Inc"),
).not.toBeInTheDocument();
expect(
screen.queryByText("ProdAcme – Personal"),
).not.toBeInTheDocument();
expect(screen.queryByText(/Beta Co/)).not.toBeInTheDocument();
});
it("labels an org as 'Personal Workspace' when /me reports user_id === org.id", async () => {
const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{
id: personalOrgId,
// The auto-generated personal-workspace org has an unfriendly
// backend-side name; the GUI must override it.
name: `user_${personalOrgId}_org`,
},
{ id: "org-2", name: "Acme Inc" },
],
currentOrgId: personalOrgId,
});
// /me for the personal org returns user_id === org_id; for the team
// org user_id !== org_id.
vi.mocked(getCloudOrganizationMe).mockImplementation(async (orgId) => ({
orgId,
userId: orgId === personalOrgId ? personalOrgId : "some-user",
}));
renderWithProviders(
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
});
}}
>
<BackendSelector />
</TestSeed>,
);
await openDropdown();
await waitFor(() => {
expect(
screen.getByText("Production – BACKEND$PERSONAL_WORKSPACE"),
).toBeInTheDocument();
});
expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument();
// The auto-generated org name must NOT be rendered.
expect(
screen.queryByText(`Production – user_${personalOrgId}_org`),
).not.toBeInTheDocument();
});
it("self-heals (cloud, null) → (cloud, personal-workspace org) once orgs + /me resolve", async () => {
const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270";
vi.mocked(getCloudOrganizations).mockResolvedValue({
items: [
{ id: personalOrgId, name: "Auto-generated personal" },
{ id: "org-2", name: "Acme Inc" },
],
currentOrgId: personalOrgId,
});
vi.mocked(getCloudOrganizationMe).mockResolvedValue({
orgId: personalOrgId,
userId: personalOrgId,
});
let cloudId = "";
renderWithProviders(
<TestSeed
onMount={(ctx) => {
cloudId = ctx.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
}).id;
// Simulate the post-refresh malformed state: active backend is
// the cloud one but no orgId is set yet.
ctx.setActive(cloudId, null);
}}
>
<BackendSelector />
</TestSeed>,
);
// After orgs + /me resolve, the selector should have snapped the
// selection onto the personal-workspace org and fired switchOrg.
await waitFor(() => {
expect(switchCloudOrganization).toHaveBeenCalled();
});
expect(switchCloudOrganization).toHaveBeenCalledWith(
personalOrgId,
expect.objectContaining({ host: "https://app.all-hands.dev" }),
);
await waitFor(() => {
const stored = JSON.parse(
window.localStorage.getItem("openhands-active-backend") ?? "null",
);
expect(stored).toEqual({ backendId: cloudId, orgId: personalOrgId });
});
});
it("switches the active backend when an option is selected", async () => {
renderWithProviders(
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
}}
>
<BackendSelector />
</TestSeed>,
);
const user = await openDropdown();
await user.click(screen.getByText("Local 1"));
const wrapper = screen.getByTestId("backend-selector");
const input = wrapper.querySelector("input") as HTMLInputElement;
expect(input.value).toBe("Local 1");
});
it("redirects to home when switching backends from a conversation route", async () => {
function ConversationRoute() {
return (
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
}}
>
<BackendSelector />
</TestSeed>
);
}
function HomeRoute() {
return <div data-testid="home" />;
}
const RouterStub = createRoutesStub([
{ path: "/conversations/:conversationId", Component: ConversationRoute },
{ path: "/", Component: HomeRoute },
]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<RouterStub initialEntries={["/conversations/abc"]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
const user = await openDropdown();
await user.click(screen.getByText("Local 1"));
expect(await screen.findByTestId("home")).toBeInTheDocument();
});
it("does not redirect when switching backends from a non-conversation route", async () => {
function SettingsRoute() {
return (
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
}}
>
<BackendSelector />
</TestSeed>
);
}
function HomeRoute() {
return <div data-testid="home" />;
}
const RouterStub = createRoutesStub([
{ path: "/settings", Component: SettingsRoute },
{ path: "/", Component: HomeRoute },
]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<RouterStub initialEntries={["/settings"]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
const user = await openDropdown();
await user.click(screen.getByText("Local 1"));
const wrapper = screen.getByTestId("backend-selector");
const input = wrapper.querySelector("input") as HTMLInputElement;
expect(input.value).toBe("Local 1");
expect(screen.queryByTestId("home")).not.toBeInTheDocument();
});
});
@@ -1,7 +1,14 @@
import React from "react";
import { screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("#/components/features/controls/agent-status", () => ({
AgentStatus: () => <div data-testid="agent-status-stub" />,
@@ -11,8 +18,6 @@ vi.mock("#/components/features/controls/tools", () => ({
Tools: () => <div data-testid="tools-stub" />,
}));
// Sentinel: this stub only renders if a future change re-imports
// ChangeAgentButton in ChatInputActions, which would fail the assertion below.
vi.mock("#/components/features/chat/change-agent-button", () => ({
ChangeAgentButton: () => <div data-testid="change-agent-button-stub" />,
}));
@@ -29,9 +34,22 @@ vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({
// eslint-disable-next-line import/first
import { ChatInputActions } from "#/components/features/chat/components/chat-input-actions";
describe("ChatInputActions", () => {
it("does not render the Change Agent button while the planning agent feature is disabled", () => {
// Arrange + Act
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
describe("ChatInputActions Change Agent button visibility", () => {
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("hides the Change Agent button on a local backend", () => {
// Arrange + Act — default active backend is the bundled local one.
renderWithProviders(<ChatInputActions disabled={false} />);
// Assert
@@ -39,4 +57,20 @@ describe("ChatInputActions", () => {
screen.queryByTestId("change-agent-button-stub"),
).not.toBeInTheDocument();
});
it("shows the Change Agent button on a cloud backend", () => {
// Arrange
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
// Act
renderWithProviders(
<ActiveBackendProvider>
<ChatInputActions disabled={false} />
</ActiveBackendProvider>,
);
// Assert
expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument();
});
});
@@ -16,6 +16,13 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
// We'll use the actual i18next implementation but override the translation function
@@ -50,6 +57,7 @@ describe("ConversationCard", () => {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
location: { origin: "http://localhost:3000" },
});
});
@@ -505,4 +513,55 @@ describe("ConversationCard", () => {
}
},
);
describe("stop button label by active backend", () => {
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
afterEach(() => {
__resetActiveStoreForTests();
});
it("uses COMMON$STOP_CONVERSATION on a local backend", () => {
// Default active backend (no provider, no registered backends) is the
// bundled local backend.
renderWithProviders(
<ConversationCardActions
contextMenuOpen={true}
onContextMenuToggle={vi.fn()}
onStop={vi.fn()}
executionStatus={V1ExecutionStatus.RUNNING}
/>,
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"COMMON$STOP_CONVERSATION",
);
});
it("uses COMMON$CLOSE_CONVERSATION_STOP_RUNTIME on a cloud backend", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
renderWithProviders(
<ActiveBackendProvider>
<ConversationCardActions
contextMenuOpen={true}
onContextMenuToggle={vi.fn()}
onStop={vi.fn()}
executionStatus={V1ExecutionStatus.RUNNING}
/>
</ActiveBackendProvider>,
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"COMMON$CLOSE_CONVERSATION_STOP_RUNTIME",
);
});
});
});
@@ -35,6 +35,7 @@ const createMockConversation = (overrides: Partial<V1AppConversation> = {}): V1A
trigger: null,
pr_number: [],
session_api_key: null,
sandbox_id: null,
sub_conversation_ids: [],
...overrides,
});
@@ -12,14 +12,33 @@ import {
import { renderWithProviders } from "test-utils";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import type { Backend } from "#/api/backend-registry/types";
import type { Conversation } from "#/api/open-hands.types";
const localBackend: Backend = {
id: "bundled",
name: "Bundled",
host: "http://localhost:3000",
apiKey: "",
kind: "local",
};
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
// Hoisted mocks for controllable return values
const {
mockMutate,
mockDisplaySuccessToast,
useActiveConversationMock,
useConfigMock,
useActiveBackendMock,
} = vi.hoisted(() => ({
mockMutate: vi.fn(),
mockDisplaySuccessToast: vi.fn(),
@@ -33,6 +52,7 @@ const {
useConfigMock: vi.fn(() => ({
data: {},
})),
useActiveBackendMock: vi.fn(),
}));
vi.mock("#/hooks/query/use-active-conversation", () => ({
@@ -49,8 +69,13 @@ vi.mock("#/hooks/mutation/use-update-conversation", () => ({
}),
}));
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => useActiveBackendMock(),
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: mockDisplaySuccessToast,
displayErrorToast: vi.fn(),
}));
// Mock react-i18next
@@ -69,7 +94,8 @@ vi.mock("react-i18next", async () => {
CONVERSATION$SHOW_SKILLS: "Show Skills",
BUTTON$DISPLAY_COST: "Display Cost",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation",
"Stop Conversation (Runtime)",
COMMON$STOP_CONVERSATION: "Stop Conversation",
COMMON$DELETE_CONVERSATION: "Delete Conversation",
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
@@ -107,6 +133,13 @@ describe("ConversationName", () => {
});
});
beforeEach(() => {
useActiveBackendMock.mockReturnValue({
backend: localBackend,
orgId: null,
});
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -355,6 +388,13 @@ describe("ConversationNameContextMenu", () => {
onClose: vi.fn(),
};
beforeEach(() => {
useActiveBackendMock.mockReturnValue({
backend: localBackend,
orgId: null,
});
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -545,7 +585,7 @@ describe("ConversationNameContextMenu", () => {
"Delete Conversation",
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"Close Conversation",
"Stop Conversation",
);
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
"Display Cost",
@@ -581,3 +621,101 @@ describe("ConversationNameContextMenu", () => {
});
});
describe("ConversationName public sharing", () => {
let updatePublicFlagSpy: ReturnType<typeof vi.spyOn>;
let writeTextSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
if (!("clipboard" in navigator)) {
Object.defineProperty(globalThis.navigator, "clipboard", {
configurable: true,
value: { writeText: () => Promise.resolve() },
});
}
writeTextSpy = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined);
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
public: false,
} as Conversation,
});
useActiveBackendMock.mockReturnValue({
backend: cloudBackend,
orgId: null,
});
updatePublicFlagSpy = vi
.spyOn(V1ConversationService, "updateConversationPublicFlag")
.mockResolvedValue({ id: "test-conversation-id", public: true } as never);
});
afterEach(() => {
updatePublicFlagSpy.mockRestore();
writeTextSpy.mockRestore();
vi.clearAllMocks();
});
it("renders the Public Share menu item on cloud backends", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
await user.click(screen.getByTestId("ellipsis-button"));
expect(screen.getByTestId("share-publicly-button")).toBeInTheDocument();
});
it("hides the Public Share menu item on local backends", async () => {
useActiveBackendMock.mockReturnValue({
backend: localBackend,
orgId: null,
});
const user = userEvent.setup();
renderConversationNameWithRouter();
await user.click(screen.getByTestId("ellipsis-button"));
expect(
screen.queryByTestId("share-publicly-button"),
).not.toBeInTheDocument();
});
it("calls the update service with the toggled flag when clicked", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
await user.click(screen.getByTestId("ellipsis-button"));
await user.click(screen.getByTestId("share-publicly-button"));
expect(updatePublicFlagSpy).toHaveBeenCalledWith(
"test-conversation-id",
true,
);
});
it("uses the cloud environment domain for the share link and clipboard copy", async () => {
useActiveConversationMock.mockReturnValue({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
public: true,
} as Conversation,
});
const expectedUrl =
"https://app.all-hands.dev/shared/conversations/test-conversation-id";
const user = userEvent.setup();
renderConversationNameWithRouter();
await user.click(screen.getByTestId("ellipsis-button"));
expect(screen.getByTestId("open-share-link-button")).toHaveAttribute(
"href",
expectedUrl,
);
await user.click(screen.getByTestId("copy-share-link-button"));
expect(writeTextSpy).toHaveBeenCalledWith(expectedUrl);
});
});
@@ -40,6 +40,7 @@ const baseConversation: V1AppConversation = {
trigger: null,
pr_number: [],
session_api_key: null,
sandbox_id: null,
sub_conversation_ids: [],
};
@@ -10,6 +10,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { GitRepository } from "#/types/git";
import { useHomeStore } from "#/stores/home-store";
import { useWorkspacesStore } from "#/stores/workspaces-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
// Create mock functions
const mockUseUserRepositories = vi.fn();
@@ -346,7 +347,7 @@ describe("RepositorySelectionForm", () => {
})
}
>
{children}
<ActiveBackendProvider>{children}</ActiveBackendProvider>
</QueryClientProvider>
),
});
@@ -140,6 +140,8 @@ describe("TaskCard", () => {
git_provider: MOCK_TASK_1.git_provider,
},
undefined,
undefined,
undefined,
);
});
});
@@ -168,6 +168,8 @@ describe("WorkspaceSelectionForm", () => {
undefined,
null,
"/Users/me/dev/repo2",
undefined,
undefined,
);
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-xyz"),
@@ -0,0 +1,66 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { LaunchTabs } from "#/components/features/home/launch-tabs";
// LaunchTabs's children pull a lot of git/data — stub them so the test
// stays focused on the tab visibility decision.
vi.mock("#/components/features/home/repo-selection-form", () => ({
RepositorySelectionForm: () => <div data-testid="stub-repo-form" />,
}));
vi.mock("#/components/features/home/workspace-selection-form", () => ({
WorkspaceSelectionForm: () => <div data-testid="stub-workspace-form" />,
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "k",
kind: "cloud",
};
function renderTabs() {
return render(
<QueryClientProvider client={new QueryClient()}>
<ActiveBackendProvider>
<LaunchTabs onRepoSelection={() => {}} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("LaunchTabs", () => {
it("shows the Workspaces tab when active backend is local", () => {
renderTabs();
expect(screen.getByTestId("repositories-tab")).toBeInTheDocument();
expect(screen.getByTestId("workspaces-tab")).toBeInTheDocument();
});
it("hides the Workspaces tab when active backend is cloud", () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
renderTabs();
expect(screen.getByTestId("repositories-tab")).toBeInTheDocument();
expect(screen.queryByTestId("workspaces-tab")).not.toBeInTheDocument();
});
});
+56 -9
View File
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
vi.mock("#/hooks/use-settings-nav-items", () => ({
useSettingsNavItems: () => [
@@ -13,25 +14,71 @@ vi.mock("#/hooks/use-settings-nav-items", () => ({
],
}));
function renderUserActions() {
return render(
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
<ActiveBackendProvider>
<UserActions
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</ActiveBackendProvider>
</QueryClientProvider>
</MemoryRouter>,
);
}
describe("UserActions", () => {
it("shows the OSS user menu on hover without hosted-only actions", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />
</QueryClientProvider>
</MemoryRouter>,
);
renderUserActions();
await user.hover(screen.getByTestId("user-actions"));
expect(screen.getByTestId("user-context-menu")).toBeVisible();
expect(screen.getByTestId("backend-selector")).toBeInTheDocument();
expect(screen.getByTestId("add-backend-menu-item")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$NAV_APPLICATION")).toBeInTheDocument();
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
});
it("opens the Add Backend modal and hides the context menu when 'Add Backend' is clicked", async () => {
const user = userEvent.setup();
renderUserActions();
await user.hover(screen.getByTestId("user-actions"));
await user.click(screen.getByTestId("add-backend-menu-item"));
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
// While the modal is open, the menu wrapper must lose BOTH the
// explicit visibility classes AND the `group-hover:*` classes — the
// latter is what was keeping the menu visible even after the menu
// state was closed.
const wrapper = screen.getByTestId("user-context-menu-wrapper");
expect(wrapper.className).not.toMatch(/(?:^|\s)opacity-100(?:\s|$)/);
expect(wrapper.className).not.toMatch(
/(?:^|\s)pointer-events-auto(?:\s|$)/,
);
expect(wrapper.className).not.toContain("group-hover:opacity-100");
expect(wrapper.className).not.toContain("group-hover:pointer-events-auto");
});
it("survives extraneous keypresses while the modal is open (Escape does not close it)", async () => {
const user = userEvent.setup();
renderUserActions();
await user.hover(screen.getByTestId("user-actions"));
await user.click(screen.getByTestId("add-backend-menu-item"));
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByTestId("add-backend-modal")).toBeInTheDocument();
});
});
@@ -0,0 +1,131 @@
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 { BUNDLED_BACKEND_ID } from "#/api/backend-registry/types";
import {
ActiveBackendProvider,
useActiveBackendContext,
} from "#/contexts/active-backend-context";
function makeWrapper(queryClient = new QueryClient()) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>{children}</ActiveBackendProvider>
</QueryClientProvider>
);
}
return Wrapper;
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("ActiveBackendProvider", () => {
it("exposes the bundled backend by default", () => {
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(),
});
expect(result.current.active.backend.id).toBe(BUNDLED_BACKEND_ID);
expect(result.current.backends).toEqual([]);
});
it("addBackend persists and exposes the new backend", () => {
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(),
});
act(() => {
result.current.addBackend({
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-1",
kind: "cloud",
});
});
expect(result.current.backends).toHaveLength(1);
expect(result.current.backends[0]).toMatchObject({
name: "Production",
kind: "cloud",
});
});
it("setActive switches the active backend without touching unrelated React Query cache entries", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(["dummy"], { value: 1 });
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(queryClient),
});
let added: { id: string } | null = null;
act(() => {
added = result.current.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "key-1",
kind: "local",
});
});
act(() => {
result.current.setActive(added!.id);
});
expect(result.current.active.backend.id).toBe(added!.id);
// No blanket cache mutation: long-lived hooks include the active
// backend identity in their query keys, so refetches happen via
// key change rather than via an explicit invalidate from setActive.
const dummyState = queryClient.getQueryState(["dummy"]);
expect(dummyState?.isInvalidated).toBe(false);
expect(queryClient.getQueryData(["dummy"])).toEqual({ value: 1 });
});
it("removeBackend falls back to bundled if the active was removed", () => {
const { result } = renderHook(() => useActiveBackendContext(), {
wrapper: makeWrapper(),
});
let id = "";
act(() => {
id = result.current.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
}).id;
});
act(() => {
result.current.setActive(id);
});
expect(result.current.active.backend.id).toBe(id);
act(() => {
result.current.removeBackend(id);
});
expect(result.current.active.backend.id).toBe(BUNDLED_BACKEND_ID);
expect(result.current.backends).toEqual([]);
});
it("throws if used outside the provider", () => {
function HookConsumer() {
useActiveBackendContext();
return null;
}
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<HookConsumer />)).toThrow(/ActiveBackendProvider/);
errorSpy.mockRestore();
});
});
+43 -4
View File
@@ -1,6 +1,14 @@
import React from "react";
import { renderHook } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useSlashCommand } from "#/hooks/chat/use-slash-command";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
const mockSkills = vi.hoisted(() => ({
data: undefined as unknown[] | undefined,
@@ -31,6 +39,14 @@ function makeChatInputRef() {
return { current: document.createElement("div") };
}
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
describe("useSlashCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -39,10 +55,14 @@ describe("useSlashCommand", () => {
mockConversation.data = undefined;
});
it("excludes /new from the built-in commands while the planning agent is disabled", () => {
// Arrange
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("excludes /new from the built-in commands on a local backend", () => {
// Arrange — default active backend is the bundled local one.
mockConversation.data = { conversation_version: "V1" };
mockSkills.isLoading = false;
mockSkills.data = [makeSkill("code-search", ["/code-search"])];
// Act
@@ -54,4 +74,23 @@ describe("useSlashCommand", () => {
expect(commands).not.toContain("/new");
expect(commands).toEqual(expect.arrayContaining(["/btw", "/code-search"]));
});
it("includes /new in the built-in commands on a cloud backend", () => {
// Arrange
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
mockConversation.data = { conversation_version: "V1" };
mockSkills.data = [];
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(ActiveBackendProvider, null, children);
// Act
const ref = makeChatInputRef();
const { result } = renderHook(() => useSlashCommand(ref), { wrapper });
// Assert
const commands = result.current.filteredItems.map((i) => i.command);
expect(commands).toContain("/new");
});
});
@@ -63,8 +63,8 @@ describe("useAddGitProviders", () => {
});
expect(invalidateSpy).toHaveBeenCalled();
expect(queryClient.getQueryState(personalSettingsQueryKey)?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(personalSettingsQueryKey)?.isInvalidated,
).toBe(true);
});
});
@@ -81,6 +81,8 @@ describe("useCreateConversation", () => {
git_provider: "github",
},
undefined,
undefined,
undefined,
);
});
});
@@ -45,6 +45,7 @@ const mockConversation = {
selected_repository: null,
selected_branch: null,
git_provider: null,
sandbox_id: "sandbox-abc",
conversation_version: "V1" as const,
};
@@ -114,8 +115,9 @@ describe("useNewConversationCommand", () => {
});
});
it("throws when the new conversation id is missing", async () => {
it("throws when the start task ends in ERROR", async () => {
const errorTask = makeStartTask({
status: "ERROR",
detail: "Setup failed",
app_conversation_id: null,
});
@@ -131,6 +133,30 @@ describe("useNewConversationCommand", () => {
await expect(result.current.mutateAsync()).rejects.toThrow("Setup failed");
});
it("navigates to /conversations/task-{id} for a cloud WORKING task without app_conversation_id", async () => {
const workingTask = makeStartTask({
status: "WORKING",
detail: null,
app_conversation_id: null,
});
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
workingTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), {
wrapper,
});
await result.current.mutateAsync();
await waitFor(() => {
// Format matches OpenHands' SaaS pattern: useTaskPolling unwraps
// `task-{uuid}` and polls until READY, then redirects.
expect(mockNavigate).toHaveBeenCalledWith("/conversations/task-task-789");
});
});
it("invalidates conversation list queries on success", async () => {
const readyTask = makeStartTask();
@@ -156,6 +182,35 @@ describe("useNewConversationCommand", () => {
});
});
it("forwards the active conversation's sandbox_id so /new reuses the same runtime", async () => {
// Arrange
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
// Act
const { result } = renderHook(() => useNewConversationCommand(), {
wrapper,
});
await result.current.mutateAsync();
// Assert — sandbox_id is the 8th positional argument; parent_conversation_id
// and agent_type stay undefined because /new is NOT a sub-conversation.
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"sandbox-abc",
);
});
});
it("shows a loading toast and dismisses it on success", async () => {
const readyTask = makeStartTask();
@@ -0,0 +1,58 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSwitchCloudOrganization } from "#/hooks/mutation/use-switch-cloud-organization";
vi.mock("#/api/cloud/organization-service.api", () => ({
switchCloudOrganization: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
function makeWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe("useSwitchCloudOrganization", () => {
it("on success removes per-conversation queries and leaves unrelated cache entries untouched", async () => {
const orgId = "fd950a0a-25bc-48ff-b4dd-ad53a990fb82";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(["user", "conversation", "abc"], { dummy: 1 });
queryClient.setQueryData(["unrelated"], { dummy: 1 });
const { result } = renderHook(() => useSwitchCloudOrganization(), {
wrapper: makeWrapper(queryClient),
});
act(() => {
result.current.mutate({ orgId });
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Per-conversation queries must be physically removed (their org
// context is stale).
expect(
queryClient.getQueryData(["user", "conversation", "abc"]),
).toBeUndefined();
// Other queries are NOT invalidated by this hook — they refetch via
// the active-backend key change driven by `setActive` in the caller.
expect(queryClient.getQueryData(["unrelated"])).toEqual({ dummy: 1 });
});
});
@@ -36,9 +36,11 @@ const createWrapper = () => {
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return function ({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe("useUpdateConversationRepository", () => {
@@ -54,9 +56,9 @@ describe("useUpdateConversationRepository", () => {
git_provider: "github",
};
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
mockResponse as any,
);
vi.mocked(
V1ConversationService.updateConversationRepository,
).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
@@ -73,7 +75,9 @@ describe("useUpdateConversationRepository", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
expect(
V1ConversationService.updateConversationRepository,
).toHaveBeenCalledWith(
"test-conversation-id",
"owner/repo",
"main",
@@ -89,9 +93,9 @@ describe("useUpdateConversationRepository", () => {
git_provider: null,
};
vi.mocked(V1ConversationService.updateConversationRepository).mockResolvedValue(
mockResponse as any,
);
vi.mocked(
V1ConversationService.updateConversationRepository,
).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
@@ -108,18 +112,15 @@ describe("useUpdateConversationRepository", () => {
expect(result.current.isSuccess).toBe(true);
});
expect(V1ConversationService.updateConversationRepository).toHaveBeenCalledWith(
"test-conversation-id",
null,
null,
null,
);
expect(
V1ConversationService.updateConversationRepository,
).toHaveBeenCalledWith("test-conversation-id", null, null, null);
});
it("should handle errors gracefully", async () => {
vi.mocked(V1ConversationService.updateConversationRepository).mockRejectedValue(
new Error("Failed to update repository"),
);
vi.mocked(
V1ConversationService.updateConversationRepository,
).mockRejectedValue(new Error("Failed to update repository"));
const { result } = renderHook(() => useUpdateConversationRepository(), {
wrapper: createWrapper(),
@@ -32,6 +32,7 @@ describe("useV1ResumeConversation", () => {
created_by_user_id: null,
conversation_url: "http://localhost:3000",
session_api_key: "test-key",
sandbox_id: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
@@ -64,7 +65,8 @@ describe("useV1ResumeConversation", () => {
const invalidateCalls = invalidateSpy.mock.calls.map((call) => call[0]);
const conversationInvalidation = invalidateCalls.find(
(call) =>
call?.queryKey?.[0] === "user" && call?.queryKey?.[1] === "conversation",
call?.queryKey?.[0] === "user" &&
call?.queryKey?.[1] === "conversation",
);
expect(conversationInvalidation).toBeDefined();
@@ -0,0 +1,129 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
const getCloudOrganizationMeMock = vi.fn();
const useAllCloudOrganizationsMock = vi.fn();
vi.mock("#/api/cloud/organization-service.api", () => ({
getCloudOrganizationMe: (...args: unknown[]) =>
getCloudOrganizationMeMock(...args),
}));
vi.mock("#/hooks/query/use-cloud-organizations", () => ({
useAllCloudOrganizations: () => useAllCloudOrganizationsMock(),
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>{children}</ActiveBackendProvider>
</QueryClientProvider>
);
}
return Wrapper;
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
getCloudOrganizationMeMock.mockReset();
useAllCloudOrganizationsMock.mockReset();
useAllCloudOrganizationsMock.mockReturnValue({
[cloudBackend.id]: {
backend: cloudBackend,
isLoading: false,
orgs: [
{ id: "org-personal", name: "Personal" },
{ id: "org-2", name: "Acme Inc" },
],
},
});
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("useCloudCurrentUserId", () => {
it("uses active.orgId for /me when the active backend is this cloud backend", async () => {
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
getCloudOrganizationMeMock.mockResolvedValue({
orgId: "org-2",
userId: "user-X",
});
const { result } = renderHook(() => useCloudCurrentUserId(), {
wrapper: makeWrapper(),
});
await waitFor(() => {
expect(result.current[cloudBackend.id]?.userId).toBe("user-X");
});
// /me must be invoked with the ACTIVE org (org-2), not the first org.
expect(getCloudOrganizationMeMock).toHaveBeenCalledOnce();
expect(getCloudOrganizationMeMock).toHaveBeenCalledWith(
"org-2",
cloudBackend,
);
});
it("falls back to the first org for /me when no org is selected yet", async () => {
// Active is bundled local — `prod` is registered but not active.
getCloudOrganizationMeMock.mockResolvedValue({
orgId: "org-personal",
userId: "user-X",
});
const { result } = renderHook(() => useCloudCurrentUserId(), {
wrapper: makeWrapper(),
});
await waitFor(() => {
expect(result.current[cloudBackend.id]?.userId).toBe("user-X");
});
// Sentinel = first org.
expect(getCloudOrganizationMeMock).toHaveBeenCalledWith(
"org-personal",
cloudBackend,
);
});
it("issues exactly one /me call per cloud backend (not one per org)", async () => {
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" });
getCloudOrganizationMeMock.mockResolvedValue({
orgId: "org-2",
userId: "user-X",
});
renderHook(() => useCloudCurrentUserId(), { wrapper: makeWrapper() });
await waitFor(() => {
expect(getCloudOrganizationMeMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -82,7 +82,16 @@ describe("useConversationHistory", () => {
expect(result.current.data).toBeDefined();
});
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123");
// searchEventsV1 now accepts (conversationId, limit, conversationUrl,
// sessionApiKey). The latter two are forwarded so cloud-mode calls
// can target the correct host. In this test fixture they're null
// (no conversation_url / session_api_key on the mocked conversation).
expect(EventService.searchEventsV1).toHaveBeenCalledWith(
"conv-123",
100,
null,
null,
);
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useConversationMetrics } from "#/hooks/query/use-conversation-metrics";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
const runtimeInfo = {
id: "conv-abc",
title: "Test",
metrics: null,
created_at: "2026-04-16T00:00:00Z",
updated_at: "2026-04-16T00:00:00Z",
status: V1ExecutionStatus.IDLE,
stats: {
usage_to_metrics: {
agent: {
model_name: "test-model",
accumulated_cost: 2.5,
max_budget_per_task: null,
accumulated_token_usage: null,
costs: [],
response_latencies: [],
token_usages: [],
},
},
},
};
function makeWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("useConversationMetrics", () => {
it("fires the query when sessionApiKey is null (local backends without auth)", async () => {
// Arrange
const spy = vi
.spyOn(V1ConversationService, "getRuntimeConversation")
.mockResolvedValue(runtimeInfo);
// Act
const { result } = renderHook(
() =>
useConversationMetrics(
"conv-abc",
"http://localhost:8888/api/conversations/conv-abc",
null,
true,
),
{ wrapper: makeWrapper() },
);
// Assert
await waitFor(() => {
expect(result.current.data?.accumulated_cost).toBe(2.5);
});
expect(spy).toHaveBeenCalledWith(
"conv-abc",
"http://localhost:8888/api/conversations/conv-abc",
null,
);
});
});
@@ -25,9 +25,13 @@ describe("useSubConversationTaskPolling", () => {
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return function ({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
};
const createMockTask = (
@@ -26,6 +26,7 @@ function makeConversation(): V1AppConversation {
execution_status: V1ExecutionStatus.RUNNING,
conversation_url: null,
session_api_key: null,
sandbox_id: null,
sub_conversation_ids: [],
created_by_user_id: null,
trigger: null,
@@ -5,11 +5,38 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import { WebClientConfig } from "#/api/option-service/option.types";
const useConfigMock = vi.fn();
const useActiveBackendMock = vi.fn();
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => useActiveBackendMock(),
}));
const localActive = {
backend: {
id: "__bundled__",
name: "Local",
host: "http://localhost",
apiKey: "",
kind: "local" as const,
},
orgId: null,
};
const cloudActive = {
backend: {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer",
kind: "cloud" as const,
},
orgId: null,
};
const createConfig = (
feature_flags: Partial<WebClientConfig["feature_flags"]> = {},
): WebClientConfig => ({
@@ -34,6 +61,7 @@ const createConfig = (
describe("useSettingsNavItems", () => {
beforeEach(() => {
vi.clearAllMocks();
useActiveBackendMock.mockReturnValue(localActive);
});
it("returns the OSS settings items in order", () => {
@@ -64,4 +92,23 @@ describe("useSettingsNavItems", () => {
expect(paths).toContain("/settings/mcp");
expect(paths).toContain("/settings/secrets");
});
it("hides local-only sub-pages when the active backend is cloud", () => {
useConfigMock.mockReturnValue({ data: createConfig() });
useActiveBackendMock.mockReturnValue(cloudActive);
const { result } = renderHook(() => useSettingsNavItems());
const paths = result.current
.filter((item) => item.type === "item")
.map((item) => (item.type === "item" ? item.item.to : null));
expect(paths).not.toContain("/settings/agent-server");
expect(paths).not.toContain("/settings/integrations");
expect(paths).toContain("/settings");
expect(paths).toContain("/settings/condenser");
expect(paths).toContain("/settings/verification");
expect(paths).toContain("/settings/mcp");
expect(paths).toContain("/settings/skills");
expect(paths).toContain("/settings/secrets");
});
});
@@ -5,6 +5,7 @@ import { createRoutesStub } from "react-router";
import MainApp from "#/routes/root-layout";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
// Hoisted mocks for useIsAuthed and useConfig to allow dynamic control in tests
const { useIsAuthedMock, useConfigMock } = vi.hoisted(() => ({
@@ -75,7 +76,7 @@ describe("MainApp - Auth refetch behavior", () => {
render(<RouterStub initialEntries={["/settings"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
<ActiveBackendProvider>{children}</ActiveBackendProvider>
</QueryClientProvider>
),
});
+4
View File
@@ -26,6 +26,10 @@ vi.mock("#/hooks/use-sync-posthog-consent", () => ({
useSyncPostHogConsent: () => {},
}));
vi.mock("#/hooks/query/use-cloud-git-user", () => ({
useCloudGitUser: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("#/hooks/use-app-title", () => ({
useAppTitle: () => "OpenHands",
}));
+46 -1
View File
@@ -1,9 +1,15 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OptionService from "#/api/option-service/option-service.api";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { getFirstAvailablePath } from "#/utils/settings-utils";
import { OSS_NAV_ITEMS } from "#/constants/settings-nav";
@@ -14,9 +20,24 @@ vi.mock("#/hooks/use-settings-nav-items", () => ({
],
}));
const cloudBackend: Backend = {
id: "prod",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-token",
kind: "cloud",
};
describe("settings route", () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
__resetActiveStoreForTests();
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
it("prefers OSS fallback routes only", () => {
@@ -61,6 +82,30 @@ describe("settings route", () => {
expect(response.headers.get("Location")).toBe("/settings/mcp");
});
it("redirects local-only settings paths to /settings when the active backend is cloud", async () => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id });
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const integrationsResponse = (await clientLoader({
request: new Request("http://localhost/settings/integrations"),
params: {},
context: {},
} as never)) as Response;
const agentServerResponse = (await clientLoader({
request: new Request("http://localhost/settings/agent-server"),
params: {},
context: {},
} as never)) as Response;
expect(integrationsResponse.status).toBe(302);
expect(integrationsResponse.headers.get("Location")).toBe("/settings");
expect(agentServerResponse.status).toBe(302);
expect(agentServerResponse.headers.get("Location")).toBe("/settings");
expect(getConfigSpy).not.toHaveBeenCalled();
});
it("skips backend config loading for the agent server settings route", async () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
@@ -0,0 +1,55 @@
import { fileURLToPath } from "node:url";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildExtraBackendConfig } from "../../scripts/dev-extra-backend.mjs";
import { buildSafeDevConfig } from "../../scripts/dev-safe.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
describe("buildExtraBackendConfig", () => {
it("defaults to ports 18002/18003 distinct from the bundled instance", () => {
const bundled = buildSafeDevConfig(repoRoot, {});
const extra = buildExtraBackendConfig(repoRoot, {});
expect(extra.backendPort).toBe(18002);
expect(extra.vscodePort).toBe(18003);
expect(extra.backendBaseUrl).toBe("http://127.0.0.1:18002");
expect(extra.backendHost).toBe("127.0.0.1:18002");
expect(extra.backendPort).not.toBe(bundled.backendPort);
expect(extra.vscodePort).not.toBe(bundled.vscodePort);
});
it("honors OH_CANVAS_EXTRA_BACKEND_PORT and OH_CANVAS_EXTRA_VSCODE_PORT", () => {
const config = buildExtraBackendConfig(repoRoot, {
OH_CANVAS_EXTRA_BACKEND_PORT: "29000",
OH_CANVAS_EXTRA_VSCODE_PORT: "29001",
});
expect(config.backendPort).toBe(29000);
expect(config.vscodePort).toBe(29001);
expect(config.backendBaseUrl).toBe("http://127.0.0.1:29000");
});
it("shares state dir, conversations, bash events, and secret key with the bundled config", () => {
const env = { OH_CANVAS_SAFE_STATE_DIR: "/tmp/canvas-state" };
const bundled = buildSafeDevConfig(repoRoot, env);
const extra = buildExtraBackendConfig(repoRoot, env);
expect(extra.stateDir).toBe(bundled.stateDir);
expect(extra.conversationsPath).toBe(bundled.conversationsPath);
expect(extra.bashEventsDir).toBe(bundled.bashEventsDir);
expect(extra.tmuxTmpDir).toBe(bundled.tmuxTmpDir);
expect(extra.secretKey).toBe(bundled.secretKey);
});
it("rejects an invalid OH_CANVAS_EXTRA_BACKEND_PORT", () => {
expect(() =>
buildExtraBackendConfig(repoRoot, {
OH_CANVAS_EXTRA_BACKEND_PORT: "not-a-port",
}),
).toThrow(/Invalid port/);
});
});
+93
View File
@@ -11,7 +11,10 @@ import {
buildNpmScriptCommand,
buildAgentServerCommand,
formatMissingUvxGuidance,
validateLocalAgentServerPath,
} from "../../scripts/dev-safe.mjs";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -118,6 +121,96 @@ describe("buildAgentServerCommand", () => {
);
expect(cmd.args).not.toContain("openhands-agent-server==1.18.0");
});
it("uses local path with editable workspace packages when OH_AGENT_SERVER_LOCAL_PATH is set", () => {
const sdk = "/abs/path/to/software-agent-sdk";
const cmd = buildAgentServerCommand({ OH_AGENT_SERVER_LOCAL_PATH: sdk });
expect(cmd.command).toBe("uvx");
expect(cmd.args).toEqual([
"--reinstall",
"--from",
path.join(sdk, "openhands-agent-server"),
"--with-editable",
path.join(sdk, "openhands-sdk"),
"--with-editable",
path.join(sdk, "openhands-tools"),
"--with-editable",
path.join(sdk, "openhands-workspace"),
"agent-server",
]);
expect(cmd.source).toBe(`local (${sdk})`);
});
it("local path takes precedence over git ref and version", () => {
const sdk = "/abs/path/to/software-agent-sdk";
const cmd = buildAgentServerCommand({
OH_AGENT_SERVER_LOCAL_PATH: sdk,
OH_AGENT_SERVER_GIT_REF: "feature-branch",
OH_AGENT_SERVER_VERSION: "1.18.0",
});
expect(cmd.source).toBe(`local (${sdk})`);
expect(cmd.args).toContain(path.join(sdk, "openhands-agent-server"));
expect(cmd.args).not.toContain(
"git+https://github.com/OpenHands/software-agent-sdk@feature-branch#subdirectory=openhands-agent-server",
);
expect(cmd.args).not.toContain("openhands-agent-server==1.18.0");
});
it("rejects relative OH_AGENT_SERVER_LOCAL_PATH", () => {
expect(() =>
buildAgentServerCommand({
OH_AGENT_SERVER_LOCAL_PATH: "./software-agent-sdk",
}),
).toThrow(/must be an absolute path/);
});
});
describe("validateLocalAgentServerPath", () => {
it("passes when all four workspace packages exist", () => {
const tmp = mkdtempSync(path.join(tmpdir(), "sdk-"));
try {
for (const subdir of [
"openhands-agent-server",
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
]) {
mkdirSync(path.join(tmp, subdir));
}
expect(() => validateLocalAgentServerPath(tmp)).not.toThrow();
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("throws when the path does not exist", () => {
expect(() =>
validateLocalAgentServerPath("/definitely/does/not/exist/sdk"),
).toThrow(/does not exist/);
});
it("throws when a workspace package subdirectory is missing", () => {
const tmp = mkdtempSync(path.join(tmpdir(), "sdk-"));
try {
mkdirSync(path.join(tmp, "openhands-agent-server"));
mkdirSync(path.join(tmp, "openhands-sdk"));
mkdirSync(path.join(tmp, "openhands-tools"));
// openhands-workspace is intentionally absent
expect(() => validateLocalAgentServerPath(tmp)).toThrow(
/openhands-workspace/,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it("throws when given a relative path", () => {
expect(() => validateLocalAgentServerPath("./sdk")).toThrow(
/must be an absolute path/,
);
});
});
describe("buildSafeDevConfig", () => {
@@ -19,7 +19,11 @@ describe("convertRawProvidersToList", () => {
azure_devops: "test-token",
};
expect(convertRawProvidersToList(example1)).toEqual(["github", "gitlab", "azure_devops"]);
expect(convertRawProvidersToList(example1)).toEqual([
"github",
"gitlab",
"azure_devops",
]);
expect(convertRawProvidersToList(example2)).toEqual(["github"]);
expect(convertRawProvidersToList(example3)).toEqual(["gitlab"]);
expect(convertRawProvidersToList(example4)).toEqual(["azure_devops"]);
@@ -73,7 +73,9 @@ describe("extractModelAndProvider", () => {
separator: "/",
});
expect(extractModelAndProvider("openhands/claude-opus-4-5-20251101")).toEqual({
expect(
extractModelAndProvider("openhands/claude-opus-4-5-20251101"),
).toEqual({
provider: "openhands",
model: "claude-opus-4-5-20251101",
separator: "/",
+9 -2
View File
@@ -89,7 +89,12 @@ describe("getInvalidEmails", () => {
});
test("returns all invalid emails", () => {
const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"];
const emails = [
"valid@example.com",
"invalid",
"test@",
"another@valid.org",
];
expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]);
});
@@ -148,7 +153,9 @@ describe("hasDuplicates", () => {
});
test("returns true when duplicates exist", () => {
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true);
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(
true,
);
});
test("returns true for case-insensitive duplicates", () => {
+18 -6
View File
@@ -16,27 +16,39 @@ describe("local-storage utilities", () => {
describe("setLoginMethod", () => {
it("stores the login method in local storage", () => {
setLoginMethod(LoginMethod.GITHUB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"github",
);
});
it("stores different login methods correctly", () => {
setLoginMethod(LoginMethod.GITLAB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"gitlab",
);
setLoginMethod(LoginMethod.BITBUCKET);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"bitbucket",
);
setLoginMethod(LoginMethod.AZURE_DEVOPS);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"azure_devops",
);
setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"bitbucket_data_center",
);
});
it("overwrites previous login method", () => {
setLoginMethod(LoginMethod.GITHUB);
setLoginMethod(LoginMethod.GITLAB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe(
"gitlab",
);
});
});
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
import { extractSettings } from "#/utils/settings-utils";
function getLlmModel(settings: ReturnType<typeof extractSettings>): unknown {
const as = settings.agent_settings_diff as Record<string, unknown> | undefined;
const as = settings.agent_settings_diff as
| Record<string, unknown>
| undefined;
const llm = as?.llm as Record<string, unknown> | undefined;
return llm?.model;
}
@@ -17,9 +19,7 @@ describe("Model name case preservation", () => {
const settings = extractSettings(formData);
expect(getLlmModel(settings)).toBe(
"SambaNova/Meta-Llama-3.1-8B-Instruct",
);
expect(getLlmModel(settings)).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
it("should preserve openai model case", () => {
@@ -56,8 +56,6 @@ describe("Model name case preservation", () => {
expect(getLlmModel(settings)).not.toBe(
"sambanova/meta-llama-3.1-8b-instruct",
);
expect(getLlmModel(settings)).toBe(
"SambaNova/Meta-Llama-3.1-8B-Instruct",
);
expect(getLlmModel(settings)).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
});
+8 -4
View File
@@ -173,8 +173,8 @@ describe("sdk settings schema helpers", () => {
agent_settings: {
...BASE_SETTINGS.agent_settings,
critic: {
...(BASE_SETTINGS.agent_settings as Record<string, unknown>)
.critic as Record<string, unknown>,
...((BASE_SETTINGS.agent_settings as Record<string, unknown>)
.critic as Record<string, unknown>),
mode: "all_actions",
},
},
@@ -303,7 +303,9 @@ describe("sdk settings schema helpers", () => {
"llm.litellm_extra_body": true,
};
expect(buildSdkSettingsPayloadForView(schema, values, dirty, "basic")).toEqual({
expect(
buildSdkSettingsPayloadForView(schema, values, dirty, "basic"),
).toEqual({
llm: {
model: "anthropic/claude-sonnet-4-20250514",
timeout: 30,
@@ -325,7 +327,9 @@ describe("sdk settings schema helpers", () => {
mcp_config: null,
});
expect(buildSdkSettingsPayloadForView(schema, values, dirty, "all")).toEqual({
expect(
buildSdkSettingsPayloadForView(schema, values, dirty, "all"),
).toEqual({
llm: {
model: "anthropic/claude-sonnet-4-20250514",
timeout: 90,
@@ -3,15 +3,23 @@ import { shouldStartMockWorker } from "#/mocks/should-start-mock-worker";
describe("shouldStartMockWorker", () => {
it("starts the worker whenever mock API mode is enabled in the browser", () => {
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: true })).toBe(true);
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: true })).toBe(
true,
);
});
it("does not start the worker when mock API mode is disabled", () => {
expect(shouldStartMockWorker({ mockApi: "false", hasWindow: true })).toBe(false);
expect(shouldStartMockWorker({ mockApi: undefined, hasWindow: true })).toBe(false);
expect(shouldStartMockWorker({ mockApi: "false", hasWindow: true })).toBe(
false,
);
expect(shouldStartMockWorker({ mockApi: undefined, hasWindow: true })).toBe(
false,
);
});
it("does not start the worker during server-side rendering", () => {
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: false })).toBe(false);
expect(shouldStartMockWorker({ mockApi: "true", hasWindow: false })).toBe(
false,
);
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { shouldUseInstallationRepos } from "#/utils/utils";
describe("shouldUseInstallationRepos", () => {
it("returns false for null/undefined provider regardless of mode", () => {
expect(shouldUseInstallationRepos(undefined, "cloud")).toBe(false);
expect(shouldUseInstallationRepos(null, "cloud")).toBe(false);
expect(shouldUseInstallationRepos(undefined, "local")).toBe(false);
});
it("uses installations for github only when active backend is cloud", () => {
expect(shouldUseInstallationRepos("github", "cloud")).toBe(true);
expect(shouldUseInstallationRepos("github", "local")).toBe(false);
// Default (no app mode passed) preserves the old local behavior.
expect(shouldUseInstallationRepos("github")).toBe(false);
});
it("always uses installations for bitbucket variants", () => {
expect(shouldUseInstallationRepos("bitbucket", "local")).toBe(true);
expect(shouldUseInstallationRepos("bitbucket", "cloud")).toBe(true);
expect(shouldUseInstallationRepos("bitbucket_data_center", "local")).toBe(
true,
);
expect(shouldUseInstallationRepos("bitbucket_data_center", "cloud")).toBe(
true,
);
});
it("never uses installations for gitlab / azure / forgejo", () => {
expect(shouldUseInstallationRepos("gitlab", "cloud")).toBe(false);
expect(shouldUseInstallationRepos("gitlab", "local")).toBe(false);
expect(shouldUseInstallationRepos("azure_devops", "cloud")).toBe(false);
expect(shouldUseInstallationRepos("forgejo", "cloud")).toBe(false);
});
});
+14 -5
View File
@@ -7,6 +7,7 @@ import {
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import { V1ExecutionStatus } from "#/types/v1/core";
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
describe("getStatusCode", () => {
it("returns RUNNING_TASK when execution status is running", () => {
@@ -25,11 +26,7 @@ describe("getStatusCode", () => {
});
it("returns starting i18n key when task is running setup", () => {
const result = getStatusCode(
"OPEN",
null,
"STARTING_CONVERSATION",
);
const result = getStatusCode("OPEN", null, "STARTING_CONVERSATION");
expect(result).toBe(I18nKey.CONVERSATION$STARTING_CONVERSATION);
});
@@ -42,6 +39,18 @@ describe("getStatusCode", () => {
const result = getStatusCode("CLOSED", V1ExecutionStatus.IDLE);
expect(result).toBe(I18nKey.CHAT_INTERFACE$DISCONNECTED);
});
it("returns COMMON$WAITING_FOR_SANDBOX when task is waiting for sandbox", () => {
const result = getStatusCode("OPEN", null, "WAITING_FOR_SANDBOX");
expect(result).toBe(I18nKey.COMMON$WAITING_FOR_SANDBOX);
});
it("falls back to starting i18n key for an unknown task status instead of throwing", () => {
const unknownStatus =
"FUTURE_STATUS_FROM_CLOUD" as V1AppConversationStartTaskStatus;
const result = getStatusCode("OPEN", null, unknownStatus);
expect(result).toBe(I18nKey.CONVERSATION$STARTING_CONVERSATION);
});
});
describe("getIndicatorColor", () => {
+2 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, test } from "vitest";
import { describe, it, expect } from "vitest";
import { getStatusText } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
@@ -73,9 +73,7 @@ describe("getStatusText", () => {
t,
});
expect(result).toBe(
t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION),
);
expect(result).toBe(t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION));
});
it("returns READY translation when task is ready", () => {
+2
View File
@@ -52,6 +52,8 @@
},
"scripts": {
"dev": "npm run dev:automation",
"dev:safe": "node --env-file-if-exists=.env scripts/dev-safe.mjs",
"dev:extra-backend": "node --env-file-if-exists=.env scripts/dev-extra-backend.mjs",
"dev:automation": "node --env-file-if-exists=.env scripts/dev-with-automation.mjs",
"dev:minimal": "node --env-file-if-exists=.env scripts/dev-safe.mjs",
"dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
+88 -88
View File
@@ -12,104 +12,104 @@ const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener("install", function () {
self.skipWaiting();
});
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
addEventListener("message", async function (event) {
const clientId = Reflect.get(event.source || {}, "id");
if (!clientId || !self.clients) {
return
return;
}
const client = await self.clients.get(clientId)
const client = await self.clients.get(clientId);
if (!client) {
return
return;
}
const allClients = await self.clients.matchAll({
type: 'window',
})
type: "window",
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
case "KEEPALIVE_REQUEST": {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
type: "KEEPALIVE_RESPONSE",
});
break;
}
case 'INTEGRITY_CHECK_REQUEST': {
case "INTEGRITY_CHECK_REQUEST": {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
type: "INTEGRITY_CHECK_RESPONSE",
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
});
break;
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
case "MOCK_ACTIVATE": {
activeClientIds.add(clientId);
sendToClient(client, {
type: 'MOCKING_ENABLED',
type: "MOCKING_ENABLED",
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
});
break;
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
case "CLIENT_CLOSED": {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
self.registration.unregister();
}
break
break;
}
}
})
});
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
addEventListener("fetch", function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
if (event.request.mode === "navigate") {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
event.request.cache === "only-if-cached" &&
event.request.mode !== "same-origin"
) {
return
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
return;
}
const requestId = crypto.randomUUID()
@@ -122,28 +122,28 @@ addEventListener('fetch', function (event) {
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
type: "RESPONSE",
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
@@ -160,10 +160,10 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
);
}
return response
return response;
}
/**
@@ -175,30 +175,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client
return client;
}
if (client?.frameType === 'top-level') {
return client
if (client?.frameType === "top-level") {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
})
type: "window",
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
return client.visibilityState === "visible";
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
return activeClientIds.has(client.id);
});
}
/**
@@ -211,36 +211,36 @@ async function resolveMainClient(event) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
const acceptHeader = headers.get("accept");
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const values = acceptHeader.split(",").map((value) => value.trim());
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
(value) => value !== "msw/passthrough",
);
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
headers.set("accept", filteredValues.join(", "));
} else {
headers.delete('accept')
headers.delete("accept");
}
}
return fetch(requestClone, { headers })
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
@@ -248,15 +248,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
type: "REQUEST",
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
@@ -264,19 +264,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
},
},
[serializedRequest.body],
)
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
case "MOCK_RESPONSE": {
return respondWithMock(clientMessage.data);
}
case 'PASSTHROUGH': {
return passthrough()
case "PASSTHROUGH": {
return passthrough();
}
}
return passthrough()
return passthrough();
}
/**
@@ -287,21 +287,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
return reject(event.data.error);
}
resolve(event.data)
}
resolve(event.data);
};
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
]);
});
}
/**
@@ -314,17 +314,17 @@ function respondWithMock(response) {
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
return Response.error();
}
const mockedResponse = new Response(response.body, response)
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
});
return mockedResponse
return mockedResponse;
}
/**
@@ -345,5 +345,5 @@ async function serializeRequest(request) {
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
};
}
+228
View File
@@ -0,0 +1,228 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
buildAgentServerCommand,
buildAgentServerEnv,
buildSafeDevConfig,
formatMissingUvxGuidance,
validateLocalAgentServerPath,
} from "./dev-safe.mjs";
const DEFAULT_EXTRA_BACKEND_PORT = 18002;
const DEFAULT_EXTRA_VSCODE_PORT = 18003;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
function parsePort(value, fallback) {
if (value == null || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid port: ${value}`);
}
return parsed;
}
/**
* Build a config for an *extra* standalone agent-server that shares the
* bundled instance's persistence (state dir, conversations, secret key)
* but listens on a different backend + vscode port.
*
* @param {string} cwd
* @param {Record<string, string | undefined>} env
*/
export function buildExtraBackendConfig(cwd = process.cwd(), env = process.env) {
const base = buildSafeDevConfig(cwd, env);
const backendPort = parsePort(
env.OH_CANVAS_EXTRA_BACKEND_PORT,
DEFAULT_EXTRA_BACKEND_PORT,
);
const vscodePort = parsePort(
env.OH_CANVAS_EXTRA_VSCODE_PORT,
DEFAULT_EXTRA_VSCODE_PORT,
);
return {
...base,
backendPort,
vscodePort,
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
backendHost: `127.0.0.1:${backendPort}`,
};
}
function isEnoentError(error) {
return Boolean(
(error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
);
}
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// Keep polling until timeout.
}
await delay(500);
}
throw new Error(`Timed out waiting for agent-server at ${url}`);
}
function spawnProcess(command, args, options) {
const child = spawn(command, args, { stdio: "inherit", ...options });
child.once("error", (error) => {
if (isEnoentError(error) && command === "uvx") {
console.error(formatMissingUvxGuidance(options?.cwd));
} else if (isEnoentError(error)) {
console.error(
`Failed to start ${command}. Make sure it is installed and on your PATH.`,
);
} else {
console.error(`Failed to start ${command}:`, error);
}
});
return child;
}
async function main() {
const config = buildExtraBackendConfig();
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
}
for (const dir of [
config.stateDir,
config.tmuxTmpDir,
config.conversationsPath,
config.workspacesPath,
config.bashEventsDir,
]) {
mkdirSync(dir, { recursive: true });
}
const agentServerCmd = buildAgentServerCommand();
const secretKeySource = process.env.OH_SECRET_KEY
? "custom (from OH_SECRET_KEY)"
: "default (for local development)";
console.log("Starting EXTRA standalone agent-server (shared state)...");
console.log(`- agent-server: ${agentServerCmd.source}`);
console.log(`- backend: ${config.backendBaseUrl}`);
console.log(`- vscode port: ${config.vscodePort}`);
console.log(`- shared state dir: ${config.stateDir}`);
console.log(`- shared conversations: ${config.conversationsPath}`);
console.log(`- secret key: ${secretKeySource}`);
console.log("");
console.log(
"Connect via the GUI: open Add Backend, enter " +
`${config.backendBaseUrl} as the host. Leave the API key blank ` +
"unless this server is started with OH_SESSION_API_KEYS_0 set.",
);
console.log("");
const backend = spawnProcess(
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"127.0.0.1",
"--port",
String(config.backendPort),
],
{
cwd: config.cwd,
env: {
...process.env,
...buildAgentServerEnv(config),
},
},
);
let shuttingDown = false;
const shutdown = (signal = "SIGTERM") => {
if (shuttingDown) {
return;
}
shuttingDown = true;
backend.kill(signal);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const backendErrored = new Promise((_, reject) => {
backend.once("error", (error) => reject(error));
});
const backendExited = new Promise((_, reject) => {
backend.once("exit", (code, signal) => {
if (!shuttingDown) {
reject(
new Error(
`agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
),
);
}
});
});
try {
await Promise.race([
waitForServer(`${config.backendBaseUrl}/server_info`),
backendErrored,
backendExited,
]);
} catch (error) {
shutdown();
throw error;
}
console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`);
backend.once("exit", (code) => {
if (!shuttingDown) {
console.error(
`agent-server exited unexpectedly with code ${code ?? 0}`,
);
shutdown();
process.exitCode = code ?? 1;
} else {
process.exitCode = code ?? 0;
}
});
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
+61 -5
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
@@ -10,6 +10,12 @@ const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_SERVER_PACKAGE = "openhands-agent-server";
const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk";
const LOCAL_AGENT_SERVER_SUBDIRS = [
"openhands-agent-server",
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
];
// Default secret key for local development (DO NOT use in production)
const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod";
// Default to main branch until settings persistence APIs are in a released version.
@@ -51,24 +57,49 @@ export function formatMissingUvxGuidance(cwd = process.cwd()) {
/**
* Build the uvx command and arguments for running agent-server.
*
* Environment variables:
* Environment variables (highest precedence first):
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout.
* Runs the local checkout via uvx with editable installs of the workspace
* packages (openhands-sdk, openhands-tools, openhands-workspace) so source
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.18.0")
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name (takes precedence over version)
*
* If neither is set, defaults to main branch until settings persistence APIs
* If none are set, defaults to main branch until settings persistence APIs
* are released. Set OH_AGENT_SERVER_VERSION to use a released version.
*
* @param {Record<string, string | undefined>} env
* @returns {{ command: string, args: string[], source: string }}
*/
export function buildAgentServerCommand(env = process.env) {
const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
const version = env.OH_AGENT_SERVER_VERSION;
const uvxArgs = [];
let source = "";
if (gitRef) {
if (localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
uvxArgs.push(
"--reinstall",
"--from",
path.join(localPath, "openhands-agent-server"),
"--with-editable",
path.join(localPath, "openhands-sdk"),
"--with-editable",
path.join(localPath, "openhands-tools"),
"--with-editable",
path.join(localPath, "openhands-workspace"),
"agent-server",
);
source = `local (${localPath})`;
} else if (gitRef) {
// Use git ref with subdirectory syntax for uv workspace monorepo
// The software-agent-sdk repo has packages in subdirectories:
// openhands-agent-server/, openhands-tools/, openhands-workspace/
@@ -224,6 +255,27 @@ export function buildNpmScriptCommand(
};
}
export function validateLocalAgentServerPath(localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
if (!existsSync(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`,
);
}
for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) {
const subdirPath = path.join(localPath, subdir);
if (!existsSync(subdirPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`,
);
}
}
}
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
const startedAt = Date.now();
@@ -264,6 +316,10 @@ function spawnProcess(command, args, options) {
async function main() {
const config = buildSafeDevConfig();
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
}
for (const dir of [
config.stateDir,
config.tmuxTmpDir,
+22 -15
View File
@@ -1,12 +1,8 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings, SettingsValue } from "#/types/settings";
import { V1ExecutionStatus } from "#/types/v1/core";
import {
getAgentServerBaseUrl,
getAgentServerSessionApiKey,
getAgentServerWorkingDir,
getConfiguredWorkerUrls,
} from "./agent-server-config";
import { getAgentServerWorkingDir } from "./agent-server-config";
import { getEffectiveLocalBackend } from "./backend-registry/active-store";
import {
GetHooksResponse,
GetSkillsResponse,
@@ -54,7 +50,10 @@ function browserToolsEnabled() {
}
export function toConversationUrl(conversationId: string): string {
return `${getAgentServerBaseUrl()}/api/conversations/${conversationId}`;
// Local-format conversation URL — points at whichever local agent-server
// is actually serving the conversation (the bundled one when the active
// selection is cloud).
return `${getEffectiveLocalBackend().host}/api/conversations/${conversationId}`;
}
// TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE
@@ -74,7 +73,9 @@ export function toV1AppConversation(
selected_repository: metadata?.selected_repository ?? null,
selected_branch: metadata?.selected_branch ?? null,
git_provider: metadata?.git_provider ?? null,
title: info.title?.trim() ? info.title : getDefaultConversationTitle(info.id),
title: info.title?.trim()
? info.title
: getDefaultConversationTitle(info.id),
trigger: null,
pr_number: [],
llm_model: info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model,
@@ -106,7 +107,8 @@ export function toV1AppConversation(
(info.execution_status as V1AppConversation["execution_status"]) ??
V1ExecutionStatus.IDLE,
conversation_url: toConversationUrl(info.id),
session_api_key: getAgentServerSessionApiKey(),
session_api_key: getEffectiveLocalBackend().apiKey || null,
sandbox_id: null,
workspace: {
working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(),
},
@@ -365,7 +367,9 @@ export interface StartConversationOptions {
customSecrets?: Array<{ name: string; description?: string }>;
}
export function buildStartConversationRequest(options: StartConversationOptions) {
export function buildStartConversationRequest(
options: StartConversationOptions,
) {
// Use encrypted settings if provided, otherwise fall back to regular settings
const sourceAgentSettings = options.encryptedAgentSettings
? { ...options.settings, agent_settings: options.encryptedAgentSettings }
@@ -385,8 +389,9 @@ export function buildStartConversationRequest(options: StartConversationOptions)
}
: options;
const conversationSettings =
buildConfiguredConversationSettings(sourceConversationOptions);
const conversationSettings = buildConfiguredConversationSettings(
sourceConversationOptions,
);
const payload: Record<string, unknown> = {
agent,
@@ -439,8 +444,9 @@ export function buildStartConversationRequest(options: StartConversationOptions)
// Add custom secrets as LookupSecret entries
// The agent-server will fetch values at runtime from /api/settings/secrets/{name}
if (options.customSecrets && options.customSecrets.length > 0) {
const baseUrl = getAgentServerBaseUrl();
const sessionApiKey = getAgentServerSessionApiKey();
const backend = getEffectiveLocalBackend();
const baseUrl = backend.host;
const sessionApiKey = backend.apiKey || null;
const secrets: Record<string, LookupSecret> = {};
for (const secret of options.customSecrets) {
@@ -450,7 +456,8 @@ export function buildStartConversationRequest(options: StartConversationOptions)
description: secret.description,
};
// Include session API key header if configured
// Include session API key header if configured (local agent-server only —
// cloud LookupSecrets aren't fetched by the local runtime).
if (sessionApiKey) {
lookupSecret.headers = {
"X-Session-API-Key": sessionApiKey,
+9
View File
@@ -1,4 +1,6 @@
/* eslint-disable max-classes-per-file */
import { HttpError } from "@openhands/typescript-client/client/http-client";
import { getBundledBackend } from "#/api/backend-registry/bundled";
import { createServerClient, type ServerInfo } from "#/api/typescript-client";
export const MINIMUM_SUPPORTED_AGENT_SERVER_VERSION = "1.17.0";
@@ -93,10 +95,17 @@ export const isAgentServerUnavailableError = (
error.name === "AgentServerUnavailableError");
export async function ensureCompatibleAgentServer() {
// The compatibility check is a *local* agent-server concern — it verifies
// that the runtime hosting the GUI is at the right version. It must NEVER
// run against the active backend, because cloud SaaS hosts don't expose
// /api/server_info and would fail with a CORS error besides.
const bundled = getBundledBackend();
let serverInfo: ServerInfo;
try {
serverInfo = await createServerClient({
host: bundled.host,
sessionApiKey: bundled.apiKey || null,
timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
}).getServerInfo();
} catch (error) {
+128
View File
@@ -0,0 +1,128 @@
import { getBundledBackend } from "./bundled";
import {
readStoredActiveBackend,
readStoredBackends,
writeStoredActiveBackend,
writeStoredBackends,
} from "./storage";
import {
BUNDLED_BACKEND_ID,
type Backend,
type BackendSelection,
type ResolvedActiveBackend,
} from "./types";
type Listener = () => void;
interface Snapshot {
backends: Backend[];
selection: BackendSelection | null;
active: ResolvedActiveBackend;
}
function computeSnapshot(
backends: Backend[],
selection: BackendSelection | null,
): Snapshot {
const bundled = getBundledBackend();
let activeBackend: Backend = bundled;
let activeOrgId: string | null = null;
if (selection) {
activeOrgId = selection.orgId ?? null;
if (selection.backendId !== BUNDLED_BACKEND_ID) {
const found = backends.find((b) => b.id === selection.backendId);
if (found) {
activeBackend = found;
} else {
activeOrgId = null; // selection points at a removed backend
}
}
}
return {
backends,
selection,
active: { backend: activeBackend, orgId: activeOrgId },
};
}
let snapshot: Snapshot = computeSnapshot(
readStoredBackends(),
readStoredActiveBackend(),
);
const listeners = new Set<Listener>();
function notify(): void {
listeners.forEach((listener) => listener());
}
export function getActiveBackend(): ResolvedActiveBackend {
return snapshot.active;
}
/**
* Pick the backend to use for *local agent-server protocol* calls.
*
* 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 SaaS host. When the user has chosen a
* cloud backend as active, those calls fall back to the bundled local
* agent-server. Cloud-only call sites import `getActiveBackend` directly.
*/
export function getEffectiveLocalBackend(): Backend {
const active = snapshot.active.backend;
if (active.kind === "cloud") return getBundledBackend();
return active;
}
export function getRegisteredBackends(): Backend[] {
return snapshot.backends;
}
export function getActiveSelection(): BackendSelection | null {
return snapshot.selection;
}
export function getSnapshot(): Snapshot {
return snapshot;
}
export function setActiveSelection(selection: BackendSelection | null): void {
writeStoredActiveBackend(selection);
snapshot = computeSnapshot(snapshot.backends, selection);
notify();
}
export function setRegisteredBackends(backends: Backend[]): void {
writeStoredBackends(backends);
let nextSelection = snapshot.selection;
if (
nextSelection &&
nextSelection.backendId !== BUNDLED_BACKEND_ID &&
!backends.some((b) => b.id === nextSelection!.backendId)
) {
nextSelection = null;
writeStoredActiveBackend(null);
}
snapshot = computeSnapshot(backends, nextSelection);
notify();
}
export function subscribeActiveBackend(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Test-only: re-read storage and clear listeners. */
// eslint-disable-next-line @typescript-eslint/naming-convention
export function __resetActiveStoreForTests(): void {
snapshot = computeSnapshot(readStoredBackends(), readStoredActiveBackend());
listeners.clear();
}
+17
View File
@@ -0,0 +1,17 @@
import type { Backend } from "./types";
/**
* Build the auth headers to send to a backend.
*
* Local agent-server uses `X-Session-API-Key`. Cloud SaaS expects a bearer
* token in the `Authorization` header.
*/
export function buildAuthHeaders(backend: Backend): Record<string, string> {
if (!backend.apiKey) return {};
if (backend.kind === "cloud") {
return { Authorization: `Bearer ${backend.apiKey}` };
}
return { "X-Session-API-Key": backend.apiKey };
}
+17
View File
@@ -0,0 +1,17 @@
import {
getAgentServerBaseUrl,
getAgentServerSessionApiKey,
} from "../agent-server-config";
import { BUNDLED_BACKEND_ID, type Backend } from "./types";
export const BUNDLED_BACKEND_NAME = "Local";
export function getBundledBackend(): Backend {
return {
id: BUNDLED_BACKEND_ID,
name: BUNDLED_BACKEND_NAME,
host: getAgentServerBaseUrl(),
apiKey: getAgentServerSessionApiKey() ?? "",
kind: "local",
};
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./types";
export * from "./storage";
export * from "./bundled";
export * from "./active-store";
export * from "./auth";
+88
View File
@@ -0,0 +1,88 @@
import type { Backend, BackendKind, BackendSelection } from "./types";
export const BACKENDS_STORAGE_KEY = "openhands-backends";
export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";
function isValidKind(value: unknown): value is BackendKind {
return value === "local" || value === "cloud";
}
function isValidBackend(value: unknown): value is Backend {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<Backend>;
return (
typeof v.id === "string" &&
v.id.length > 0 &&
typeof v.name === "string" &&
typeof v.host === "string" &&
typeof v.apiKey === "string" &&
isValidKind(v.kind)
);
}
export function readStoredBackends(): Backend[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidBackend);
} catch {
return [];
}
}
export function writeStoredBackends(backends: Backend[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
} catch {
/* ignore quota / serialization errors */
}
}
export function readStoredActiveBackend(): BackendSelection | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
typeof (parsed as BackendSelection).backendId !== "string"
) {
return null;
}
const orgIdRaw = (parsed as BackendSelection).orgId;
return {
backendId: (parsed as BackendSelection).backendId,
orgId:
typeof orgIdRaw === "string" && orgIdRaw.length > 0 ? orgIdRaw : null,
};
} catch {
return null;
}
}
export function writeStoredActiveBackend(
selection: BackendSelection | null,
): void {
if (typeof window === "undefined") return;
try {
if (!selection) {
window.localStorage.removeItem(ACTIVE_BACKEND_STORAGE_KEY);
return;
}
window.localStorage.setItem(
ACTIVE_BACKEND_STORAGE_KEY,
JSON.stringify({
backendId: selection.backendId,
orgId: selection.orgId ?? null,
}),
);
} catch {
/* ignore */
}
}
+21
View File
@@ -0,0 +1,21 @@
export type BackendKind = "local" | "cloud";
export interface Backend {
id: string;
name: string;
host: string;
apiKey: string;
kind: BackendKind;
}
export interface BackendSelection {
backendId: string;
orgId?: string | null;
}
export interface ResolvedActiveBackend {
backend: Backend;
orgId: string | null;
}
export const BUNDLED_BACKEND_ID = "__bundled__";
+29
View File
@@ -0,0 +1,29 @@
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
/**
* Cloud (SaaS) authentication probe. Routed through the bundled local
* agent-server's `/api/cloud-proxy` to avoid cross-origin browser calls.
*
* Returns true if the API key is accepted by the cloud backend.
*/
export async function authenticateCloud(backend?: Backend): Promise<boolean> {
const target =
backend ??
(() => {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error(
"authenticateCloud requires a cloud backend. Active backend is local.",
);
}
return active;
})();
await callCloudProxy<unknown>({
backend: target,
method: "POST",
path: "/api/authenticate",
});
return true;
}
+211
View File
@@ -0,0 +1,211 @@
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import type {
V1AppConversation,
V1AppConversationPage,
V1AppConversationStartRequest,
V1AppConversationStartTask,
} from "../conversation-service/v1-conversation-service.types";
import { callCloudProxy } from "./proxy";
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud conversations call requires a cloud backend.");
}
return active;
}
/**
* Search the cloud SaaS app-conversations list. Mirrors the local
* `V1ConversationService.searchConversations` interface but routes
* through the bundled agent-server's cloud proxy and hits the SaaS
* endpoint `/api/v1/app-conversations/search`.
*/
export async function searchCloudConversations(
limit: number = 20,
pageId?: string,
): Promise<V1AppConversationPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageId) params.set("page_id", pageId);
params.set("sort_order", "UPDATED_AT_DESC");
const data = await callCloudProxy<{
items: V1AppConversation[];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/app-conversations/search?${params.toString()}`,
});
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
}
/**
* Batch-fetch cloud app-conversations by id. Mirrors the local
* `V1ConversationService.batchGetAppConversations` interface.
*/
export async function batchGetCloudConversations(
ids: string[],
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) return [];
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
for (const id of ids) params.append("ids", id);
const data = await callCloudProxy<(V1AppConversation | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations?${params.toString()}`,
});
return data ?? [];
}
/**
* Create a v1 app-conversation on the cloud SaaS.
*
* Mirrors OpenHands' SaaS flow: POST /api/v1/app-conversations with the
* `V1AppConversationStartRequest` payload, returning a
* `V1AppConversationStartTask`. The task is initially WORKING; the caller
* polls `getCloudAppConversationStartTask` (3s cadence per OpenHands)
* until status is READY (then `app_conversation_id`, `agent_server_url`,
* and `session_api_key` are populated) or ERROR.
*
* This path does NOT use encrypted-settings round-tripping. Secrets stay
* server-side on the SaaS — the only auth carried is the cloud bearer
* token (via the proxy's headers), and the conversation runtime is
* provisioned with its own ephemeral session_api_key returned in the
* task.
*/
export async function createCloudAppConversation(
request: V1AppConversationStartRequest,
): Promise<V1AppConversationStartTask> {
const backend = getActiveCloudBackend();
const data = await callCloudProxy<V1AppConversationStartTask>({
backend,
method: "POST",
path: "/api/v1/app-conversations",
body: request as unknown as Record<string, unknown>,
});
return data;
}
/**
* Download a v1 app-conversation as a ZIP from the cloud SaaS. Mirrors
* the local `V1ConversationService.downloadConversation` interface but
* routes through the bundled agent-server's cloud proxy and hits
* `GET /api/v1/app-conversations/{id}/download`, which returns
* `application/zip` with `Content-Disposition` set by the SaaS.
*/
export async function downloadCloudConversation(
conversationId: string,
): Promise<Blob> {
const backend = getActiveCloudBackend();
return callCloudProxy<Blob>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/download`,
responseType: "blob",
});
}
/**
* Delete a v1 app-conversation on the cloud SaaS. Mirrors the local
* `V1ConversationService.deleteConversation` interface but routes
* through the bundled agent-server's cloud proxy and hits
* `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).
*/
export async function deleteCloudConversation(
conversationId: string,
): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "DELETE",
path: `/api/v1/app-conversations/${conversationId}`,
});
}
/**
* Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
* OpenHands' `V1ConversationService.updateConversationPublicFlag` —
* routes through the bundled agent-server's cloud proxy and hits
* `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
* the updated conversation.
*/
export async function updateCloudConversationPublicFlag(
conversationId: string,
isPublic: boolean,
): Promise<V1AppConversation> {
const backend = getActiveCloudBackend();
const data = await callCloudProxy<V1AppConversation>({
backend,
method: "PATCH",
path: `/api/v1/app-conversations/${conversationId}`,
body: { public: isPublic },
});
return data;
}
/**
* Pause the cloud sandbox backing a v1 app-conversation. Mirrors
* OpenHands' `SandboxService.pauseSandbox` — routes through the
* bundled agent-server's cloud proxy and hits
* `POST /api/v1/sandboxes/{sandboxId}/pause` on the SaaS, which stops
* the runtime owning the conversation.
*/
export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: `/api/v1/sandboxes/${sandboxId}/pause`,
});
}
/**
* Read a file from a cloud conversation's sandbox workspace. Mirrors
* OpenHands' `V1ConversationService.readConversationFile` — hits
* `GET /api/v1/app-conversations/{id}/file?file_path=...` on the SaaS
* and returns the file content as a string.
*/
export async function readCloudConversationFile(
conversationId: string,
filePath: string,
): Promise<string> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.append("file_path", filePath);
const data = await callCloudProxy<string>({
backend,
method: "GET",
path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
});
return data ?? "";
}
/**
* Fetch a single v1 app-conversation start task. Mirrors OpenHands'
* `V1ConversationService.getStartTask` — uses the batch search endpoint
* with a single id and unwraps the first result.
*/
export async function getCloudAppConversationStartTask(
taskId: string,
): Promise<V1AppConversationStartTask | null> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("ids", taskId);
const data = await callCloudProxy<(V1AppConversationStartTask | null)[]>({
backend,
method: "GET",
path: `/api/v1/app-conversations/start-tasks?${params.toString()}`,
});
return data?.[0] ?? null;
}
+104
View File
@@ -0,0 +1,104 @@
import type {
BranchPage,
GitRepository,
InstallationPage,
RepositoryPage,
} from "#/types/git";
import type { Provider } from "#/types/settings";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud git call requires a cloud backend.");
}
return active;
}
export async function searchCloudRepositories(args: {
provider: Provider;
query?: string;
limit?: number;
pageId?: string;
installationId?: string;
}): Promise<RepositoryPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("provider", args.provider);
params.set("limit", String(args.limit ?? 100));
if (args.query) params.set("query", args.query);
if (args.pageId) params.set("page_id", args.pageId);
if (args.installationId) params.set("installation_id", args.installationId);
const data = await callCloudProxy<{
items: GitRepository[];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/git/repositories/search?${params.toString()}`,
});
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
}
export async function getCloudInstallations(args: {
provider: Provider;
pageId?: string;
limit?: number;
}): Promise<InstallationPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("provider", args.provider);
params.set("limit", String(args.limit ?? 100));
if (args.pageId) params.set("page_id", args.pageId);
const data = await callCloudProxy<{
items: string[];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/git/installations/search?${params.toString()}`,
});
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
}
export async function getCloudRepositoryBranches(args: {
provider: Provider;
repository: string;
query?: string;
pageId?: string;
limit?: number;
}): Promise<BranchPage> {
const backend = getActiveCloudBackend();
const params = new URLSearchParams();
params.set("provider", args.provider);
params.set("repository", args.repository);
params.set("limit", String(args.limit ?? 30));
if (args.query) params.set("query", args.query);
if (args.pageId) params.set("page_id", args.pageId);
const data = await callCloudProxy<{
items: BranchPage["items"];
next_page_id: string | null;
}>({
backend,
method: "GET",
path: `/api/v1/git/branches/search?${params.toString()}`,
});
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
}
+119
View File
@@ -0,0 +1,119 @@
import axios from "axios";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
import type {
CloudApiKeyMetadata,
CloudOrganization,
CloudOrganizationsResponse,
} from "./types";
interface OrganizationsResult {
items: CloudOrganization[];
currentOrgId: string | null;
}
function normalizeResult(
data: CloudOrganizationsResponse | undefined | null,
): OrganizationsResult {
return {
items: data?.items ?? [],
currentOrgId: data?.current_org_id ?? null,
};
}
function resolveBackend(backend?: Backend): Backend {
if (backend) return backend;
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error(
"Cloud organization calls require a cloud backend. Active backend is local.",
);
}
return active;
}
/**
* Fetch the org list for a cloud backend. With no argument, uses the active
* 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.
*/
export async function getCloudOrganizations(
backend?: Backend,
): Promise<OrganizationsResult> {
const target = resolveBackend(backend);
const data = await callCloudProxy<CloudOrganizationsResponse>({
backend: target,
method: "GET",
path: "/api/organizations",
});
return normalizeResult(data);
}
/**
* Fetch metadata for the API key used to authenticate this cloud backend.
* The returned `orgId` is the single org the key is authorized to act on
* (the SaaS contract: one key → one org).
*
* Legacy keys minted before per-key org binding existed cause the upstream
* to return HTTP 400 — we surface that as `isLegacyKey: true` with a null
* `orgId` so the caller can fall back to the unfiltered behavior. Other
* statuses (401 revoked, 5xx outage) propagate so React Query can mark
* the query as failed and the selector can render the no-org-known row.
*/
export async function getCurrentCloudApiKey(
backend?: Backend,
): Promise<{ orgId: string | null; isLegacyKey: boolean }> {
const target = resolveBackend(backend);
try {
const data = await callCloudProxy<CloudApiKeyMetadata>({
backend: target,
method: "GET",
path: "/api/keys/current",
});
return { orgId: data?.org_id ?? null, isLegacyKey: false };
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 400) {
return { orgId: null, isLegacyKey: true };
}
throw e;
}
}
export async function switchCloudOrganization(
orgId: string,
backend?: Backend,
): Promise<void> {
const target = resolveBackend(backend);
await callCloudProxy<unknown>({
backend: target,
method: "POST",
path: `/api/organizations/${encodeURIComponent(orgId)}/switch`,
});
}
/**
* Fetch `GET /api/organizations/{orgId}/me`. Identifies the calling user as
* a member of `orgId`. The GUI uses `me.org_id === me.user_id` to decide
* whether `orgId` is the user's personal workspace — that's the SaaS
* contract (the auto-generated personal-workspace org has the same id as
* the user).
*/
export async function getCloudOrganizationMe(
orgId: string,
backend?: Backend,
): Promise<{ orgId: string; userId: string }> {
const target = resolveBackend(backend);
const data = await callCloudProxy<{ org_id: string; user_id: string }>({
backend: target,
method: "GET",
path: `/api/organizations/${encodeURIComponent(orgId)}/me`,
});
return {
orgId: data?.org_id ?? orgId,
userId: data?.user_id ?? "",
};
}
+100
View File
@@ -0,0 +1,100 @@
import axios from "axios";
import { buildAuthHeaders } from "../backend-registry/auth";
import { getBundledBackend } from "../backend-registry/bundled";
import type { Backend } from "../backend-registry/types";
interface CloudProxyRequest {
/**
* Cloud backend whose bearer token authenticates the upstream call.
* `backend.host` is also the default upstream host unless `hostOverride`
* is set.
*/
backend: Backend;
/** HTTP method against the upstream host. */
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
/** Path on the upstream host, e.g. "/api/v1/conversation/123/events/search". */
path: string;
/** Optional JSON body for non-GET methods. */
body?: unknown;
/** Extra headers merged with the auth header for the upstream call. */
headers?: Record<string, string>;
/** Override the upstream timeout, in seconds. */
timeoutSeconds?: number;
/**
* Override the upstream host. When set, the proxy targets this host
* instead of `backend.host`. Used for runtime-sandbox calls where the
* upstream lives at the conversation's runtime URL (e.g.
* `http://<id>.prod-runtime.all-hands.dev`) rather than the SaaS API.
* The host must still pass the proxy's allowlist server-side.
*/
hostOverride?: string;
/**
* Auth strategy for the upstream call. Defaults to "bearer" (uses the
* cloud backend's bearer token via `buildAuthHeaders`). For
* runtime-sandbox calls, set to "session-api-key" and pass
* `sessionApiKey` — those endpoints don't accept bearer tokens, only
* `X-Session-API-Key`. "none" sends no auth header.
*/
authMode?: "bearer" | "session-api-key" | "none";
/** Required when `authMode === "session-api-key"`. */
sessionApiKey?: string | null;
/**
* Axios responseType for the inner POST to the bundled agent-server.
* Set to "blob" when the upstream cloud endpoint returns a binary
* payload (e.g. ZIP downloads); leave undefined for default JSON.
*/
responseType?: "blob";
}
function buildUpstreamAuthHeaders(
req: CloudProxyRequest,
): Record<string, string> {
const mode = req.authMode ?? "bearer";
if (mode === "bearer") return buildAuthHeaders(req.backend);
if (mode === "session-api-key") {
return req.sessionApiKey ? { "X-Session-API-Key": req.sessionApiKey } : {};
}
return {};
}
/**
* POST a cloud-proxy envelope to the bundled 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 → SaaS or browser → runtime-sandbox call.
*
* Auth headers (bearer or session-api-key) are attached server-side; they
* never cross an origin boundary in the browser.
*/
export async function callCloudProxy<TResponse = unknown>(
req: CloudProxyRequest,
): Promise<TResponse> {
const bundled = getBundledBackend();
const upstreamHeaders = {
...buildUpstreamAuthHeaders(req),
...(req.headers ?? {}),
};
const upstreamHost = req.hostOverride ?? req.backend.host;
// Talk directly to the bundled local agent-server, bypassing the global
// openHands axios interceptor (which would otherwise read host + auth
// from the active backend — wrong for this call).
const response = await axios.post<TResponse>(
`${bundled.host.replace(/\/+$/, "")}/api/cloud-proxy`,
{
host: upstreamHost,
method: req.method,
path: req.path,
headers: upstreamHeaders,
body: req.body ?? null,
...(req.timeoutSeconds ? { timeout_seconds: req.timeoutSeconds } : {}),
},
{
headers: buildAuthHeaders(bundled),
timeout: 30_000,
...(req.responseType ? { responseType: req.responseType } : {}),
},
);
return response.data;
}
+89
View File
@@ -0,0 +1,89 @@
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import type { CustomSecretWithoutValue } from "../secrets-service.types";
import { callCloudProxy } from "./proxy";
interface CloudSecretsPage {
items: CustomSecretWithoutValue[];
next_page_id: string | null;
}
const PAGE_LIMIT = 100;
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud secrets call requires a cloud backend.");
}
return active;
}
/**
* Walk every page of the cloud SaaS `/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.
*/
export async function fetchCloudSecrets(): Promise<CustomSecretWithoutValue[]> {
const backend = getActiveCloudBackend();
const all: CustomSecretWithoutValue[] = [];
let pageId: string | null = null;
do {
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
if (pageId) query.set("page_id", pageId);
const page: CloudSecretsPage = await callCloudProxy<CloudSecretsPage>({
backend,
method: "GET",
path: `/api/v1/secrets/search?${query.toString()}`,
});
all.push(...(page.items ?? []));
pageId = page.next_page_id ?? null;
} while (pageId);
return all;
}
export async function createCloudSecret(
name: string,
value: string,
description?: string,
): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "POST",
path: "/api/v1/secrets",
body: { name, value, description },
});
}
/**
* Rename and/or redescribe an existing cloud secret. The cloud `PUT` endpoint
* does not accept a value field — it only updates name + description — which
* matches what `useUpdateSecret` actually sends from the secret-edit form.
*/
export async function updateCloudSecret(
secretToEdit: string,
name: string,
description?: string,
): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "PUT",
path: `/api/v1/secrets/${encodeURIComponent(secretToEdit)}`,
body: { name, description },
});
}
export async function deleteCloudSecret(name: string): Promise<void> {
const backend = getActiveCloudBackend();
await callCloudProxy<unknown>({
backend,
method: "DELETE",
path: `/api/v1/secrets/${encodeURIComponent(name)}`,
});
}
+191
View File
@@ -0,0 +1,191 @@
import {
type MCPConfig,
type Provider,
type Settings,
type SettingsValue,
} from "#/types/settings";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
/**
* The cloud SaaS Settings response is mostly flat — top-level fields like
* `llm_model`, `provider_tokens_set`, etc., rather than the nested
* `{ agent_settings, conversation_settings }` shape the local agent-server
* uses. We deliberately do NOT remap cloud fields into the local shape:
* the GUI's `Settings` type already supports both layouts (it has flat
* fields AND nested `agent_settings`/`conversation_settings`), and
* cloud-aware hooks like `useUserProviders` read directly from the flat
* `provider_tokens_set` field. So the cloud response is passed through as
* a `Partial<Settings>`, with a small derivation step to also populate
* the nested fields the local-mode settings UI consumes.
*/
type CloudSettingsResponse = {
llm_model?: string;
llm_base_url?: string;
llm_api_key?: string | null;
llm_api_key_set?: boolean;
search_api_key_set?: boolean;
agent?: string;
confirmation_mode?: boolean;
security_analyzer?: string | null;
max_iterations?: number | null;
enable_default_condenser?: boolean;
condenser_max_size?: number | null;
enable_proactive_conversation_starters?: boolean;
enable_solvability_analysis?: boolean;
enable_sound_notifications?: boolean;
language?: string;
email?: string;
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
user_consents_to_analytics?: boolean | null;
is_new_user?: boolean;
v1_enabled?: boolean;
remote_runtime_resource_factor?: number | null;
max_budget_per_task?: number | null;
provider_tokens_set?: Partial<Record<Provider, string | null>>;
mcp_config?: MCPConfig;
disabled_skills?: string[];
agent_settings?: Record<string, SettingsValue> | null;
conversation_settings?: Record<string, SettingsValue> | null;
agent_settings_schema?: unknown;
conversation_settings_schema?: unknown;
[key: string]: unknown;
};
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud settings call requires a cloud backend.");
}
return active;
}
/**
* Build the nested `agent_settings` block from the cloud's flat fields.
* Used so the local-mode settings page (which renders against the nested
* shape) shows the right values when the active backend is cloud.
*/
function deriveAgentSettings(
flat: CloudSettingsResponse,
): Record<string, SettingsValue> {
if (flat.agent_settings && Object.keys(flat.agent_settings).length > 0) {
return flat.agent_settings;
}
const agent: Record<string, SettingsValue> = {};
const llm: Record<string, SettingsValue> = {};
if (typeof flat.llm_model === "string") llm.model = flat.llm_model;
if (typeof flat.llm_base_url === "string") llm.base_url = flat.llm_base_url;
if (typeof flat.llm_api_key === "string") llm.api_key = flat.llm_api_key;
if (Object.keys(llm).length > 0) agent.llm = llm;
const condenser: Record<string, SettingsValue> = {};
if (typeof flat.enable_default_condenser === "boolean") {
condenser.enabled = flat.enable_default_condenser;
}
if (typeof flat.condenser_max_size === "number") {
condenser.max_size = flat.condenser_max_size;
}
if (Object.keys(condenser).length > 0) agent.condenser = condenser;
if (typeof flat.agent === "string") agent.agent = flat.agent;
return agent;
}
function deriveConversationSettings(
flat: CloudSettingsResponse,
): Record<string, SettingsValue> {
if (
flat.conversation_settings &&
Object.keys(flat.conversation_settings).length > 0
) {
return flat.conversation_settings;
}
const out: Record<string, SettingsValue> = {};
if (typeof flat.confirmation_mode === "boolean") {
out.confirmation_mode = flat.confirmation_mode;
}
if (
typeof flat.security_analyzer === "string" ||
flat.security_analyzer === null
) {
out.security_analyzer = flat.security_analyzer;
}
if (typeof flat.max_iterations === "number") {
out.max_iterations = flat.max_iterations;
}
return out;
}
/**
* Fetch the cloud SaaS settings and return them as a `Partial<Settings>`.
*
* Top-level fields like `provider_tokens_set` are preserved unchanged so
* the existing `useUserProviders` → `useAppInstallations` →
* `useGitRepositories` chain (which reads `settings.provider_tokens_set`)
* fires correctly in cloud mode. Nested `agent_settings` /
* `conversation_settings` are derived for the settings page.
*/
export async function fetchCloudSettings(): Promise<Partial<Settings>> {
const backend = getActiveCloudBackend();
const flat = await callCloudProxy<CloudSettingsResponse>({
backend,
method: "GET",
path: "/api/v1/settings",
});
return {
...flat,
agent_settings: deriveAgentSettings(flat),
conversation_settings: deriveConversationSettings(flat),
llm_api_key_set: !!flat.llm_api_key_set,
search_api_key_set: !!flat.search_api_key_set,
provider_tokens_set: flat.provider_tokens_set,
} as Partial<Settings>;
}
export async function saveCloudSettings(diff: {
agent_settings_diff?: Record<string, SettingsValue>;
conversation_settings_diff?: Record<string, SettingsValue>;
}): Promise<void> {
const backend = getActiveCloudBackend();
const body: Record<string, unknown> = {};
if (
diff.agent_settings_diff &&
Object.keys(diff.agent_settings_diff).length > 0
) {
body.agent_settings_diff = diff.agent_settings_diff;
}
if (
diff.conversation_settings_diff &&
Object.keys(diff.conversation_settings_diff).length > 0
) {
body.conversation_settings_diff = diff.conversation_settings_diff;
}
await callCloudProxy<unknown>({
backend,
method: "POST",
path: "/api/v1/settings",
body,
});
}
export async function fetchCloudSettingsSchema(): Promise<unknown> {
const backend = getActiveCloudBackend();
return callCloudProxy<unknown>({
backend,
method: "GET",
path: "/api/v1/settings/agent-schema",
});
}
export async function fetchCloudConversationSettingsSchema(): Promise<unknown> {
const backend = getActiveCloudBackend();
return callCloudProxy<unknown>({
backend,
method: "GET",
path: "/api/v1/settings/conversation-schema",
});
}
+48
View File
@@ -0,0 +1,48 @@
import type { SkillInfo } from "#/types/settings";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
interface CloudSkillsPage {
items: SkillInfo[];
next_page_id: string | null;
}
const PAGE_LIMIT = 100;
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud skills call requires a cloud backend.");
}
return active;
}
/**
* Fetch the full list of skills from the cloud SaaS 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.
*/
export async function fetchCloudSkills(): Promise<SkillInfo[]> {
const backend = getActiveCloudBackend();
const all: SkillInfo[] = [];
let pageId: string | null = null;
do {
const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
if (pageId) query.set("page_id", pageId);
const page: CloudSkillsPage = await callCloudProxy<CloudSkillsPage>({
backend,
method: "GET",
path: `/api/v1/skills/search?${query.toString()}`,
});
all.push(...(page.items ?? []));
pageId = page.next_page_id ?? null;
} while (pageId);
return all;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Minimal slice of the OpenHands SaaS organization shape that the GUI needs
* to render the backend selector. Full shape lives in the OpenHands repo;
* we keep only the fields actually read by this codebase.
*/
export interface CloudOrganization {
id: string;
name: string;
is_personal?: boolean;
}
export interface CloudOrganizationsResponse {
items: CloudOrganization[];
current_org_id: string | null;
}
/**
* Subset of OpenHands' OrganizationMember that the GUI reads. Returned by
* `GET /api/organizations/{orgId}/me`. The relationship between the org and
* the current user is the source of truth for "is this a personal
* workspace?" — namely `org_id === user_id`.
*/
export interface CloudOrganizationMember {
org_id: string;
user_id: string;
email?: string | null;
role?: string;
status?: string;
}
/**
* Response from `GET /api/keys/current`. Identifies the org the calling
* API key is scoped to. Legacy keys without an org binding cause the
* upstream to return HTTP 400 instead of producing this shape — the
* caller catches that case rather than expecting a partial response.
*/
export interface CloudApiKeyMetadata {
id: string;
name: string;
org_id: string | null;
user_id: string;
auth_type: string;
}
/**
* Response from `GET /api/v1/users/git-info`. Identifies the currently
* authenticated user across the connected git providers.
*/
export interface CloudGitUser {
id: string;
login: string;
avatar_url: string;
company: string | null;
name: string | null;
email: string | null;
}
+31
View File
@@ -0,0 +1,31 @@
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";
import type { CloudGitUser } from "./types";
function getActiveCloudBackend(): Backend {
const active = getActiveBackend().backend;
if (active.kind !== "cloud") {
throw new Error("Cloud user calls require a cloud backend.");
}
return active;
}
/**
* Fetch the SaaS git-info record (`GET /api/v1/users/git-info`). Returns
* the currently authenticated user's identity across the connected git
* providers — login, avatar, etc.
*
* Routed through the bundled agent-server's cloud proxy.
*/
export async function getCloudGitUser(
backend?: Backend,
): Promise<CloudGitUser> {
const target = backend ?? getActiveCloudBackend();
const data = await callCloudProxy<CloudGitUser>({
backend: target,
method: "GET",
path: "/api/v1/users/git-info",
});
return data;
}
@@ -1,11 +1,25 @@
import { Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
import { v4 as uuidv4 } from 'uuid';
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { v4 as uuidv4 } from "uuid";
import {
buildConversationWorkingDir,
getAgentServerBaseUrl,
getAgentServerWorkingDir,
} from "../agent-server-config";
import {
getActiveBackend,
getEffectiveLocalBackend,
} from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import {
batchGetCloudConversations,
createCloudAppConversation,
deleteCloudConversation,
downloadCloudConversation,
getCloudAppConversationStartTask,
readCloudConversationFile,
searchCloudConversations,
updateCloudConversationPublicFlag,
} from "../cloud/conversation-service.api";
import {
DirectConversationInfo,
buildStartConversationRequestWithEncryptedSettings,
@@ -16,7 +30,7 @@ import {
toV1AppConversation,
toV1ConversationPage,
} from "../agent-server-adapter";
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
import { GetVSCodeUrlResponse } from "../open-hands.types";
import {
createHttpClient,
createRemoteWorkspace,
@@ -63,7 +77,35 @@ class V1ConversationService {
plugins?: PluginSpec[],
metadata?: ConversationMetadata | null,
workingDirOverride?: string,
parentConversationId?: string,
agentType?: "default" | "plan",
sandboxId?: string,
): Promise<V1AppConversationStartTask> {
if (getActiveBackend().backend.kind === "cloud") {
// Cloud SaaS path mirrors OpenHands' frontend: build a flat
// V1AppConversationStartRequest, POST /api/v1/app-conversations
// (returns a WORKING task), and let the conversation route's
// useTaskPolling drive it to READY. NO encrypted-settings
// round-trip — the SaaS holds secrets server-side.
const request: V1AppConversationStartRequest = {
initial_message: initialUserMsg
? {
role: "user",
content: [{ type: "text", text: initialUserMsg }],
}
: null,
title: conversationInstructions ?? null,
selected_repository: metadata?.selected_repository ?? null,
selected_branch: metadata?.selected_branch ?? null,
git_provider: metadata?.git_provider ?? null,
plugins: plugins ?? null,
parent_conversation_id: parentConversationId ?? null,
agent_type: agentType,
sandbox_id: sandboxId ?? null,
};
return createCloudAppConversation(request);
}
const settings = await SettingsService.getSettings();
const conversationId = uuidv4();
const workingDir =
@@ -98,7 +140,7 @@ class V1ConversationService {
status: "READY",
detail: null,
app_conversation_id: data.id,
agent_server_url: getAgentServerBaseUrl(),
agent_server_url: getEffectiveLocalBackend().host,
request: {
initial_message: payload.initial_message as
| V1AppConversationStartRequest["initial_message"]
@@ -111,8 +153,14 @@ class V1ConversationService {
}
static async getStartTask(
_taskId: string,
taskId: string,
): Promise<V1AppConversationStartTask | null> {
if (getActiveBackend().backend.kind === "cloud") {
return getCloudAppConversationStartTask(taskId);
}
// Local agent-server creates conversations synchronously — every
// local "task" is already READY when createConversation returns, so
// there's nothing to poll for.
return null;
}
@@ -124,12 +172,35 @@ class V1ConversationService {
static async getVSCodeUrl(
conversationId: string,
_conversationUrl: string | null | undefined,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const active = getActiveBackend().backend;
// Cloud mode: route through the cloud-proxy to the runtime sandbox.
// The runtime exposes a SaaS-style endpoint at `/api/vscode/url`
// that returns `{ url }`; we map it back to `{ vscode_url }` to
// match the local response shape.
if (active.kind === "cloud" && conversationUrl) {
const data = await callCloudProxy<{ url: string | null }>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: "/api/vscode/url",
authMode: "session-api-key",
sessionApiKey,
});
return { vscode_url: data?.url ?? null };
}
const workspaceDir =
await this.resolveConversationWorkingDir(conversationId);
const vscode_url = await createVSCodeClient({ sessionApiKey }).getUrl({
// Local mode: the typescript-client targets the local agent-server
// directly via the conversationUrl override.
const vscode_url = await createVSCodeClient({
conversationUrl,
sessionApiKey,
}).getUrl({
baseUrl:
typeof window !== "undefined" ? window.location.origin : undefined,
workspaceDir,
@@ -189,6 +260,10 @@ class V1ConversationService {
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) return [];
if (getActiveBackend().backend.kind === "cloud") {
return batchGetCloudConversations(ids);
}
const response = await createHttpClient().get<
(DirectConversationInfo | null)[]
>("/api/conversations", { params: { ids } });
@@ -216,10 +291,12 @@ class V1ConversationService {
static async updateConversationPublicFlag(
conversationId: string,
_isPublic: boolean,
isPublic: boolean,
): Promise<V1AppConversation> {
const results = await this.batchGetAppConversations([conversationId]);
return results[0] as V1AppConversation;
if (getActiveBackend().backend.kind !== "cloud") {
throw new Error("Public sharing requires a cloud backend.");
}
return updateCloudConversationPublicFlag(conversationId, isPublic);
}
static async updateConversationRepository(
@@ -245,6 +322,16 @@ class V1ConversationService {
conversationId: string,
filePath?: string,
): Promise<string> {
if (getActiveBackend().backend.kind === "cloud") {
// Cloud SaaS exposes a per-conversation file endpoint; the sandbox
// working dir is fixed (`/workspace/project`), so PLAN.md lives at
// a known absolute path. Mirrors OpenHands' readConversationFile.
return readCloudConversationFile(
conversationId,
filePath ?? "/workspace/project/.agents_tmp/PLAN.md",
);
}
if (filePath) {
return downloadTextFile(filePath);
}
@@ -254,6 +341,10 @@ class V1ConversationService {
}
static async downloadConversation(conversationId: string): Promise<Blob> {
if (getActiveBackend().backend.kind === "cloud") {
return downloadCloudConversation(conversationId);
}
const response = await createHttpClient().get<Blob>(
`/api/file/download-trajectory/${conversationId}`,
{
@@ -277,17 +368,41 @@ class V1ConversationService {
static async getRuntimeConversation(
conversationId: string,
_conversationUrl: string | null | undefined,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<V1RuntimeConversationInfo> {
const response = await createHttpClient({ sessionApiKey }).get<
DirectConversationInfo & { stats?: V1RuntimeConversationInfo["stats"] }
>(`/api/conversations/${conversationId}`);
const { data } = response;
const active = getActiveBackend().backend;
type RawRuntime = DirectConversationInfo & {
stats?: V1RuntimeConversationInfo["stats"];
};
// Cloud mode: route through the cloud-proxy to the runtime sandbox at
// the conversation's runtime URL — same pattern as getVSCodeUrl. Local
// mode forwards conversationUrl so the host explicitly resolves to the
// conversation's runtime instead of falling back to the active backend.
const data: RawRuntime =
active.kind === "cloud" && conversationUrl
? await callCloudProxy<RawRuntime>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/conversations/${conversationId}`,
authMode: "session-api-key",
sessionApiKey,
})
: (
await createHttpClient({
conversationUrl,
sessionApiKey,
}).get<RawRuntime>(`/api/conversations/${conversationId}`)
).data;
return {
id: data.id,
title: data.title?.trim() ? data.title : getDefaultConversationTitle(data.id),
title: data.title?.trim()
? data.title
: getDefaultConversationTitle(data.id),
metrics: data.metrics
? {
accumulated_cost: data.metrics.accumulated_cost ?? null,
@@ -324,6 +439,10 @@ class V1ConversationService {
limit: number = 20,
pageId?: string,
): Promise<V1AppConversationPage> {
if (getActiveBackend().backend.kind === "cloud") {
return searchCloudConversations(limit, pageId);
}
const response = await createHttpClient().get<{
items: DirectConversationInfo[];
next_page_id: string | null;
@@ -339,7 +458,11 @@ class V1ConversationService {
}
static async deleteConversation(conversationId: string): Promise<void> {
await createHttpClient().delete(`/api/conversations/${conversationId}`);
if (getActiveBackend().backend.kind === "cloud") {
await deleteCloudConversation(conversationId);
} else {
await createHttpClient().delete(`/api/conversations/${conversationId}`);
}
removeStoredConversationMetadata(conversationId);
}
@@ -61,11 +61,13 @@ export interface V1AppConversationStartRequest {
pr_number?: number[];
parent_conversation_id?: string | null;
agent_type?: "default" | "plan";
sandbox_id?: string | null;
plugins?: PluginSpec[] | null; // Plugins to load when starting the conversation
}
export type V1AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
@@ -116,6 +118,7 @@ export interface V1AppConversation {
execution_status: V1ExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
sandbox_id: string | null;
workspace?: V1ConversationWorkspace | null;
public?: boolean;
sub_conversation_ids: string[];
+75 -3
View File
@@ -1,18 +1,57 @@
import { OpenHandsEvent } from "#/types/v1/core";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import { createHttpClient, createRemoteEventsList } from "../typescript-client";
import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
/**
* Cloud-mode REST calls are split between two upstream hosts (matching
* OpenHands' SaaS frontend):
*
* - **App API** (`backend.host`, default in `callCloudProxy`):
* event *history* (`/api/v1/conversation/{id}/events/search`).
* Persisted by the SaaS — survives the runtime sandbox.
*
* - **Runtime sandbox** (extracted from `conversation.conversation_url`
* and passed as `hostOverride`): live runtime endpoints like
* `/api/conversations/{id}/events/count` and
* `/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 SaaS or the runtime.
*
* Local mode keeps the existing typescript-client path: it targets the
* conversation's host directly via `createRemoteEventsList`/`createHttpClient`.
*/
class EventService {
static async respondToConfirmation(
conversationId: string,
_conversationUrl: string,
conversationUrl: string,
request: ConfirmationResponseRequest,
sessionApiKey?: string | null,
): Promise<ConfirmationResponseResponse> {
const active = getActiveBackend().backend;
if (active.kind === "cloud") {
return callCloudProxy<ConfirmationResponseResponse>({
backend: active,
method: "POST",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/conversations/${conversationId}/events/respond_to_confirmation`,
body: request,
authMode: "session-api-key",
sessionApiKey,
});
}
const response = await createHttpClient({
conversationUrl,
sessionApiKey,
}).post<ConfirmationResponseResponse>(
`/api/conversations/${conversationId}/events/respond_to_confirmation`,
@@ -24,18 +63,51 @@ class EventService {
static async getEventCount(
conversationId: string,
_conversationUrl: string,
conversationUrl: string,
sessionApiKey?: string | null,
): Promise<number> {
return createRemoteEventsList(conversationId, { sessionApiKey }).count();
const active = getActiveBackend().backend;
if (active.kind === "cloud") {
return callCloudProxy<number>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/conversations/${conversationId}/events/count`,
authMode: "session-api-key",
sessionApiKey,
});
}
return createRemoteEventsList(conversationId, {
conversationUrl,
sessionApiKey,
}).count();
}
static async searchEventsV1(
conversationId: string,
limit = 100,
conversationUrl?: string | null,
sessionApiKey?: string | null,
) {
const active = getActiveBackend().backend;
if (active.kind === "cloud") {
// Event *history* lives on the SaaS App API, not the runtime
// sandbox. Path is singular `conversation` and v1-prefixed.
const params = new URLSearchParams();
params.set("limit", String(limit));
const data = await callCloudProxy<{ items?: OpenHandsEvent[] }>({
backend: active,
method: "GET",
path: `/api/v1/conversation/${conversationId}/events/search?${params.toString()}`,
});
return data?.items ?? [];
}
const page = await createRemoteEventsList(conversationId, {
conversationUrl,
sessionApiKey,
}).search({
limit,
+20 -10
View File
@@ -83,22 +83,32 @@ const requireService = (provider: Provider): GitProviderService => {
return service;
};
const firstConfiguredService = (): GitProviderService => {
const firstConfiguredService = (): GitProviderService | null => {
const all = collectAllServices();
if (all.length === 0) {
throw new GitProviderAuthError("No git provider configured");
}
return all[0];
return all[0] ?? null;
};
export const ProviderHandler = {
getServiceForProvider: getServiceFor,
async getUserGitInfo(provider?: Provider): Promise<GitUser> {
const service = provider
? requireService(provider)
: firstConfiguredService();
return service.getUser();
/**
* Resolve the calling user's git profile from any locally-configured
* provider. Returns `null` when no provider is configured — the
* caller (e.g. `useGitUser`) treats that as "no info to show", which
* is the right outcome for a clean local install or for the brief
* window after a Cloud → Local switch where settings haven't refetched
* yet. Throwing here would surface as a global error toast.
*
* When a `provider` is passed explicitly, missing local credentials
* are still an error — the caller asked for that specific provider,
* so we propagate `requireService`'s `GitProviderAuthError`.
*/
async getUserGitInfo(provider?: Provider): Promise<GitUser | null> {
if (provider) {
return requireService(provider).getUser();
}
const service = firstConfiguredService();
return service ? service.getUser() : null;
},
async getSuggestedTasks(
+58
View File
@@ -5,9 +5,17 @@ import V1ConversationService from "../conversation-service/v1-conversation-servi
import { createRemoteWorkspace } from "../typescript-client";
import { mapAnyGitStatusToV0Status } from "#/utils/git-status-mapper";
import { ProviderHandler } from "../git-providers/provider-handler";
import { getActiveBackend } from "../backend-registry/active-store";
import {
getCloudInstallations,
getCloudRepositoryBranches,
searchCloudRepositories,
} from "../cloud/git-service.api";
const safeProvider = (value: string): Provider => value as Provider;
const isCloudActive = () => getActiveBackend().backend.kind === "cloud";
class GitService {
static async searchGitRepositories(
query: string,
@@ -16,6 +24,15 @@ class GitService {
pageId?: string,
installationId?: string,
): Promise<RepositoryPage> {
if (isCloudActive()) {
return searchCloudRepositories({
provider: safeProvider(provider),
query: query || undefined,
limit,
pageId,
installationId,
});
}
return ProviderHandler.searchRepositories(safeProvider(provider), {
query: query || undefined,
installationId,
@@ -30,6 +47,14 @@ class GitService {
limit = 30,
installationId?: string,
): Promise<RepositoryPage> {
if (isCloudActive()) {
return searchCloudRepositories({
provider: safeProvider(provider),
limit,
pageId,
installationId,
});
}
return ProviderHandler.searchRepositories(safeProvider(provider), {
installationId,
pageId,
@@ -46,6 +71,14 @@ class GitService {
): Promise<RepositoryPage> {
const installationId = installations[installationIndex];
if (!installationId) return { items: [], next_page_id: null };
if (isCloudActive()) {
return searchCloudRepositories({
provider: safeProvider(provider),
installationId,
limit,
pageId,
});
}
return ProviderHandler.searchRepositories(safeProvider(provider), {
installationId,
pageId,
@@ -60,6 +93,15 @@ class GitService {
pageId?: string,
limit = 30,
): Promise<BranchPage> {
if (isCloudActive()) {
return getCloudRepositoryBranches({
provider: safeProvider(provider),
repository,
query: query || undefined,
pageId,
limit,
});
}
return ProviderHandler.getBranches(safeProvider(provider), {
repository,
query: query || undefined,
@@ -75,6 +117,15 @@ class GitService {
pageId?: string,
limit = 30,
): Promise<BranchPage> {
if (isCloudActive()) {
return getCloudRepositoryBranches({
provider: safeProvider(provider),
repository,
query,
pageId,
limit,
});
}
return ProviderHandler.getBranches(safeProvider(provider), {
repository,
query,
@@ -88,6 +139,13 @@ class GitService {
pageId?: string,
limit = 100,
): Promise<InstallationPage> {
if (isCloudActive()) {
return getCloudInstallations({
provider: safeProvider(provider),
pageId,
limit,
});
}
return ProviderHandler.getInstallations(safeProvider(provider), {
pageId,
limit,
+83 -3
View File
@@ -1,13 +1,73 @@
import { mapAnyGitStatusToV0Status } from "#/utils/git-status-mapper";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type { GitChange, GitChangeDiff } from "../open-hands.types";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
import { createRemoteWorkspace } from "../typescript-client";
interface V1GitChange {
status: string;
path: string;
}
/**
* Git operations for V1 conversations.
*
* In **local** mode the runtime is reachable directly from the browser
* (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land
* fine. In **cloud** mode the runtime is at
* `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from
* `localhost`. So cloud-mode calls go through `callCloudProxy` with the
* runtime URL as `hostOverride` and the conversation's
* `session_api_key` as the auth header — server-side hop, no CORS.
*/
/**
* The cloud runtime's `/api/git/{changes,diff}` endpoints prepend
* `/workspace/` to relative paths (so a relative arg like
* `workspace/project` becomes `/workspace/workspace/project` and 404s).
* `getGitPath` returns the local agent-server's relative convention by
* default; normalize to an absolute path before sending to the cloud
* runtime.
*/
function toAbsoluteRuntimePath(path: string): string {
return path.startsWith("/") ? path : `/${path}`;
}
class V1GitService {
static async getGitChanges(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const active = getActiveBackend().backend;
if (active.kind === "cloud" && conversationUrl) {
const params = new URLSearchParams();
params.set("path", toAbsoluteRuntimePath(path));
const data = await callCloudProxy<V1GitChange[]>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/git/changes?${params.toString()}`,
authMode: "session-api-key",
sessionApiKey,
});
if (!Array.isArray(data)) {
throw new Error(
"Invalid response from runtime - runtime may be unavailable",
);
}
return data.map((change) => ({
status: mapAnyGitStatusToV0Status(
String(change.status) as Parameters<
typeof mapAnyGitStatusToV0Status
>[0],
),
path: change.path,
}));
}
const changes = await createRemoteWorkspace({
conversationUrl,
sessionApiKey,
@@ -34,16 +94,36 @@ class V1GitService {
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const diff = await createRemoteWorkspace({
const active = getActiveBackend().backend;
if (active.kind === "cloud" && conversationUrl) {
const params = new URLSearchParams();
params.set("path", toAbsoluteRuntimePath(path));
const diff = await callCloudProxy<GitChangeDiff & { diff?: string }>({
backend: active,
method: "GET",
hostOverride: buildHttpBaseUrl(conversationUrl),
path: `/api/git/diff?${params.toString()}`,
authMode: "session-api-key",
sessionApiKey,
});
return {
modified: diff?.modified ?? "",
original: diff?.original ?? "",
...(diff?.diff ? { diff: diff.diff } : {}),
} as GitChangeDiff;
}
const diff = (await createRemoteWorkspace({
conversationUrl,
sessionApiKey,
}).gitDiff(path);
}).gitDiff(path)) as GitChangeDiff & { diff?: string };
return {
modified: diff.modified ?? "",
original: diff.original ?? "",
...(diff.diff ? { diff: diff.diff } : {}),
};
} as GitChangeDiff;
}
}
+16 -6
View File
@@ -3,10 +3,9 @@ import axios, {
AxiosResponse,
InternalAxiosRequestConfig,
} from "axios";
import {
getAgentServerBaseUrl,
getAgentServerHeaders,
} from "./agent-server-config";
import { getActiveBackend } from "./backend-registry/active-store";
import { buildAuthHeaders } from "./backend-registry/auth";
import { getBundledBackend } from "./backend-registry/bundled";
function serializeParams(
params: Record<string, unknown> | URLSearchParams,
@@ -38,12 +37,23 @@ function serializeParams(
}
export const openHands = axios.create({
baseURL: getAgentServerBaseUrl(),
paramsSerializer: { serialize: serializeParams },
});
openHands.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const headers = getAgentServerHeaders();
// The default openHands axios speaks the *local agent-server's* protocol
// (X-Session-API-Key auth, /api/* paths). When the active backend is
// cloud, fall back to the bundled local agent-server — cloud-specific
// calls go through `callCloudProxy` (which uses axios directly) and
// never hit this interceptor.
const active = getActiveBackend().backend;
const backend = active.kind === "cloud" ? getBundledBackend() : active;
// Mutating the per-request config is the canonical axios interceptor pattern.
// eslint-disable-next-line no-param-reassign
if (!config.baseURL) config.baseURL = backend.host;
const headers = buildAuthHeaders(backend);
Object.entries(headers).forEach(([key, value]) => {
config.headers.set(key, value);
});
+25
View File
@@ -1,3 +1,10 @@
import { getActiveBackend } from "./backend-registry/active-store";
import {
createCloudSecret,
deleteCloudSecret,
fetchCloudSecrets,
updateCloudSecret,
} from "./cloud/secrets-service.api";
import { createHttpClient } from "./typescript-client";
import { CustomSecretWithoutValue } from "./secrets-service.types";
import { Provider, ProviderOptions, ProviderToken } from "#/types/settings";
@@ -170,6 +177,9 @@ export class SecretsService {
*/
static async getSecrets(): Promise<CustomSecretWithoutValue[]> {
try {
if (getActiveBackend().backend.kind === "cloud") {
return await withRetry(() => fetchCloudSecrets());
}
const response = await withRetry(() =>
createHttpClient().get<SecretsListResponse>("/api/settings/secrets"),
);
@@ -197,6 +207,10 @@ export class SecretsService {
value: string,
description?: string,
): Promise<void> {
if (getActiveBackend().backend.kind === "cloud") {
await withRetry(() => createCloudSecret(name, value, description));
return;
}
await withRetry(() =>
createHttpClient().put<CreateSecretResponse>("/api/settings/secrets", {
name,
@@ -221,6 +235,13 @@ export class SecretsService {
value: string,
description?: string,
): Promise<void> {
if (getActiveBackend().backend.kind === "cloud") {
// The cloud PUT endpoint renames + redescribes only (no value field),
// matching what `useUpdateSecret` actually sends:
// (secretToEdit=name, newName=value, description).
await withRetry(() => updateCloudSecret(name, value, description));
return;
}
// Agent-server uses upsert, so update is the same as create
await this.createSecret(name, value, description);
}
@@ -234,6 +255,10 @@ export class SecretsService {
*/
static async deleteSecret(name: string): Promise<void> {
try {
if (getActiveBackend().backend.kind === "cloud") {
await withRetry(() => deleteCloudSecret(name));
return;
}
await withRetry(() =>
createHttpClient().delete<{ deleted: boolean }>(
`/api/settings/secrets/${encodeURIComponent(name)}`,
@@ -1,6 +1,18 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Provider, Settings, SettingsSchema, SettingsValue } from "#/types/settings";
import {
Provider,
Settings,
SettingsSchema,
SettingsValue,
} from "#/types/settings";
import { getStoredGitProviders } from "../secrets-service";
import { getActiveBackend } from "../backend-registry/active-store";
import {
fetchCloudConversationSettingsSchema,
fetchCloudSettings,
fetchCloudSettingsSchema,
saveCloudSettings,
} from "../cloud/settings-service.api";
import { createHttpClient, createSettingsClient } from "../typescript-client";
/**
@@ -53,7 +65,7 @@ async function withRetry<T>(
lastError = error;
if (attempt < maxRetries - 1) {
// Exponential backoff: 500ms, 1000ms, 2000ms
const delay = baseDelayMs * Math.pow(2, attempt);
const delay = baseDelayMs * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
@@ -214,6 +226,22 @@ class SettingsService {
* Uses in-memory cache for performance.
*/
static async getSettings(): Promise<Settings> {
// Cloud SaaS uses a different settings shape (flat top-level fields
// including provider_tokens_set, llm_model, etc.). Branch out before
// touching the local-only cache: cloud responses bypass the local
// SettingsApiResponse shape and feed straight into syncDerivedSettings
// so cloud-native fields like provider_tokens_set reach the GUI's
// useUserProviders → useAppInstallations → useGitRepositories chain.
if (getActiveBackend().backend.kind === "cloud") {
try {
const cloud = await withRetry(() => fetchCloudSettings());
return syncDerivedSettings(cloud);
} catch (error) {
console.warn("Failed to fetch cloud settings, using defaults:", error);
return syncDerivedSettings({});
}
}
// Check cache first
if (isCacheValid() && settingsCache.redacted) {
return syncDerivedSettings(transformApiResponse(settingsCache.redacted));
@@ -268,10 +296,16 @@ class SettingsService {
}
static async getSettingsSchema(): Promise<SettingsSchema> {
if (getActiveBackend().backend.kind === "cloud") {
return (await fetchCloudSettingsSchema()) as SettingsSchema;
}
return (await createSettingsClient().getAgentSchema()) as SettingsSchema;
}
static async getConversationSettingsSchema(): Promise<SettingsSchema> {
if (getActiveBackend().backend.kind === "cloud") {
return (await fetchCloudConversationSettingsSchema()) as SettingsSchema;
}
return (await createSettingsClient().getConversationSchema()) as SettingsSchema;
}
@@ -304,16 +338,17 @@ class SettingsService {
}
// Only call API if we have something to update
if (
!payload.agent_settings_diff &&
!payload.conversation_settings_diff
) {
if (!payload.agent_settings_diff && !payload.conversation_settings_diff) {
return true;
}
await withRetry(() =>
createHttpClient().patch<SettingsApiResponse>("/api/settings", payload),
);
if (getActiveBackend().backend.kind === "cloud") {
await withRetry(() => saveCloudSettings(payload));
} else {
await withRetry(() =>
createHttpClient().patch<SettingsApiResponse>("/api/settings", payload),
);
}
// Invalidate cache after successful save
clearCache();
+6
View File
@@ -1,9 +1,15 @@
import { SkillInfo } from "#/types/settings";
import { getAgentServerWorkingDir } from "./agent-server-config";
import { getActiveBackend } from "./backend-registry/active-store";
import { fetchCloudSkills } from "./cloud/skills-service.api";
import { createSkillsClient } from "./typescript-client";
class SkillsService {
static async getSkills(): Promise<SkillInfo[]> {
if (getActiveBackend().backend.kind === "cloud") {
return fetchCloudSkills();
}
const response = await createSkillsClient().getSkills({
load_public: true,
load_user: true,
+28 -14
View File
@@ -9,11 +9,9 @@ import { HttpClient } from "@openhands/typescript-client/client/http-client";
import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import {
getAgentServerBaseUrl,
getAgentServerSessionApiKey,
getAgentServerWorkingDir,
} from "./agent-server-config";
import { getActiveBackend } from "./backend-registry/active-store";
import { getBundledBackend } from "./backend-registry/bundled";
import { getAgentServerWorkingDir } from "./agent-server-config";
export type { ServerInfo } from "@openhands/typescript-client";
@@ -32,20 +30,36 @@ interface ResolvedClientOptions {
workingDir: string;
}
/**
* Pick the backend whose host + API key the typescript-client should use
* by default. The typescript-client clients (createHttpClient and friends)
* speak the *local agent-server's* protocol — `X-Session-API-Key` auth and
* paths like `/api/conversations`, `/api/skills`, etc. The cloud SaaS
* exposes neither, so when the active backend is cloud, fall back to the
* bundled local agent-server for these calls. Cloud-specific calls go
* through `callCloudProxy` separately and never touch this resolver.
*/
function resolveDefaultBackend() {
const active = getActiveBackend().backend;
if (active.kind === "cloud") return getBundledBackend();
return active;
}
function resolveHost(overrides: TypeScriptClientOverrides): string {
if (overrides.host) return overrides.host.replace(/\/$/, "");
if (overrides.conversationUrl)
return buildHttpBaseUrl(overrides.conversationUrl);
return resolveDefaultBackend().host;
}
function resolveClientOptions(
overrides: TypeScriptClientOverrides = {},
): ResolvedClientOptions {
const host = overrides.host
? overrides.host.replace(/\/$/, "")
: overrides.conversationUrl
? buildHttpBaseUrl(overrides.conversationUrl)
: getAgentServerBaseUrl();
const backend = resolveDefaultBackend();
const host = resolveHost(overrides);
const apiKey =
overrides.sessionApiKey ??
overrides.apiKey ??
getAgentServerSessionApiKey() ??
undefined;
overrides.sessionApiKey ?? overrides.apiKey ?? backend.apiKey ?? undefined;
return {
host,
+17 -3
View File
@@ -1,14 +1,28 @@
import { GitUser } from "#/types/git";
import { getActiveBackend } from "../backend-registry/active-store";
import { getCloudGitUser } from "../cloud/user-service.api";
import { ProviderHandler } from "../git-providers/provider-handler";
/**
* User Service API - Handles all user-related API endpoints.
*
* The OSS agent-server runtime does not expose /api/v1/users/git-info, so we
* resolve the user directly from the configured git provider in the browser.
* Local mode: the OSS agent-server runtime does not expose
* `/api/v1/users/git-info`, so we resolve the user directly from the
* configured git provider in the browser via `ProviderHandler`. If no
* provider tokens are stored locally, this throws
* "No git provider configured" — the right signal for local operation.
*
* Cloud mode: the SaaS exposes `/api/v1/users/git-info` and holds
* provider tokens server-side. There is nothing local to check, so we
* route through the cloud proxy. The local "no provider configured"
* check is meaningless for cloud and would otherwise surface as a false
* toast — branching here keeps it scoped to local.
*/
class UserService {
static async getUser(): Promise<GitUser> {
static async getUser(): Promise<GitUser | null> {
if (getActiveBackend().backend.kind === "cloud") {
return getCloudGitUser();
}
return ProviderHandler.getUserGitInfo();
}
}
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { I18nKey } from "#/i18n/declaration";
interface AddBackendMenuItemProps {
onOpen: () => void;
}
export function AddBackendMenuItem({ onOpen }: AddBackendMenuItemProps) {
const { t } = useTranslation("openhands");
return (
<button
type="button"
data-testid="add-backend-menu-item"
onClick={onOpen}
className="flex w-full items-center gap-2 p-2 rounded text-xs cursor-pointer hover:bg-white/10 hover:text-white"
>
<Plus width={16} height={16} className="text-white shrink-0" />
{t(I18nKey.BACKEND$ADD)}
</button>
);
}
@@ -0,0 +1,183 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { useActiveBackendContext } from "#/contexts/active-backend-context";
import { I18nKey } from "#/i18n/declaration";
import type { BackendKind } from "#/api/backend-registry/types";
interface AddBackendModalProps {
onClose: () => void;
}
function inferKindFromHost(host: string): BackendKind {
const trimmed = host.trim().toLowerCase();
if (trimmed.includes("all-hands.dev") || trimmed.includes("openhands.dev")) {
return "cloud";
}
return "local";
}
function normalizeHost(host: string): string {
const trimmed = host.trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function AddBackendModal({ onClose }: AddBackendModalProps) {
const { t } = useTranslation("openhands");
const { addBackend } = useActiveBackendContext();
const [name, setName] = React.useState("");
const [host, setHost] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [kind, setKind] = React.useState<BackendKind>("cloud");
const [touchedKind, setTouchedKind] = React.useState(false);
React.useEffect(() => {
if (!touchedKind && host) {
setKind(inferKindFromHost(host));
}
}, [host, touchedKind]);
const canSubmit =
name.trim().length > 0 &&
host.trim().length > 0 &&
(kind === "local" || apiKey.trim().length > 0);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit) return;
addBackend({
name: name.trim(),
host: normalizeHost(host),
apiKey: apiKey.trim(),
kind,
});
// Adding a backend is a pure save — we do NOT auto-switch the active
// selection. The user picks the new backend from the dropdown when
// they're ready. Auto-switching would write `(backendId, null)` for a
// cloud backend, which the dropdown can't render once orgs load and
// therefore drifts from the API layer.
onClose();
};
return (
<ModalBackdrop
onClose={onClose}
closeOnEscape={false}
aria-label={t(I18nKey.BACKEND$ADD_TITLE)}
>
<form
data-testid="add-backend-modal"
onSubmit={onSubmit}
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary"
style={{ width: "480px" }}
>
<div className="flex flex-col gap-1">
<h3 className="text-xl font-bold">{t(I18nKey.BACKEND$ADD_TITLE)}</h3>
<p className="text-xs text-gray-400">
{t(I18nKey.BACKEND$ADD_SUBTITLE)}
</p>
</div>
<SettingsInput
testId="add-backend-name"
name="add-backend-name"
type="text"
label={t(I18nKey.BACKEND$NAME_LABEL)}
value={name}
onChange={setName}
placeholder="Production"
className="w-full"
/>
<SettingsInput
testId="add-backend-host"
name="add-backend-host"
type="text"
label={t(I18nKey.BACKEND$HOST_LABEL)}
value={host}
onChange={setHost}
placeholder="https://app.all-hands.dev"
className="w-full"
/>
<SettingsInput
testId="add-backend-api-key"
name="add-backend-api-key"
type="password"
label={t(I18nKey.BACKEND$KEY_LABEL)}
value={apiKey}
onChange={setApiKey}
placeholder=""
className="w-full"
/>
<fieldset className="flex flex-col">
<legend className="text-sm mb-3">
{t(I18nKey.BACKEND$KIND_LABEL)}
</legend>
<div className="flex gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="add-backend-kind"
checked={kind === "local"}
onChange={() => {
setKind("local");
setTouchedKind(true);
}}
data-testid="add-backend-kind-local"
/>
{t(I18nKey.BACKEND$KIND_LOCAL)}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="add-backend-kind"
checked={kind === "cloud"}
onChange={() => {
setKind("cloud");
setTouchedKind(true);
}}
data-testid="add-backend-kind-cloud"
/>
{t(I18nKey.BACKEND$KIND_CLOUD)}
</label>
</div>
<p className="text-xs text-gray-400 mt-3">
{kind === "cloud"
? t(I18nKey.BACKEND$KEY_HELPER_CLOUD)
: t(I18nKey.BACKEND$KEY_HELPER_LOCAL)}
</p>
</fieldset>
<div className="grid grid-cols-2 gap-2 mt-2 w-full">
<BrandButton
type="submit"
variant="primary"
isDisabled={!canSubmit}
testId="add-backend-submit"
className="w-full text-center"
>
{t(I18nKey.BACKEND$SAVE)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onClose}
testId="add-backend-cancel"
className="w-full text-center"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</form>
</ModalBackdrop>
);
}
@@ -0,0 +1,183 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useMatch, useNavigate } from "react-router";
import { Dropdown } from "#/ui/dropdown/dropdown";
import { DropdownOption } from "#/ui/dropdown/types";
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";
import { useSwitchCloudOrganization } from "#/hooks/mutation/use-switch-cloud-organization";
import { I18nKey } from "#/i18n/declaration";
import type { Backend } from "#/api/backend-registry/types";
const VALUE_SEPARATOR = "::";
function makeOptionValue(backendId: string, orgId: string | null): string {
return orgId ? `${backendId}${VALUE_SEPARATOR}${orgId}` : backendId;
}
function parseOptionValue(value: string): {
backendId: string;
orgId: string | null;
} {
const [backendId, orgId] = value.split(VALUE_SEPARATOR);
return { backendId, orgId: orgId ?? null };
}
function buildOptions(
bundled: Backend,
registered: Backend[],
bundledLabel: string,
personalWorkspaceLabel: string,
cloudOrgs: ReturnType<typeof useAllCloudOrganizations>,
currentUserIds: ReturnType<typeof useCloudCurrentUserId>,
): DropdownOption[] {
const options: DropdownOption[] = [
{ value: makeOptionValue(bundled.id, null), label: bundledLabel },
];
const locals = registered.filter((b) => b.kind === "local");
const clouds = registered.filter((b) => b.kind === "cloud");
for (const b of locals) {
options.push({ value: makeOptionValue(b.id, null), label: b.name });
}
for (const b of clouds) {
const entry = cloudOrgs[b.id];
if (!entry || entry.orgs.length === 0) {
options.push({ value: makeOptionValue(b.id, null), label: b.name });
} else {
// Personal-workspace rule (per the SaaS contract): the org whose
// id matches the calling user's id is the user's personal
// workspace. We resolve `user_id` once per backend (via /me on any
// one org) and apply it across all orgs of that backend.
const userIdForBackend = currentUserIds[b.id]?.userId ?? null;
for (const org of entry.orgs) {
const isPersonal = !!userIdForBackend && userIdForBackend === org.id;
const orgLabel = isPersonal ? personalWorkspaceLabel : org.name;
options.push({
value: makeOptionValue(b.id, org.id),
label: `${b.name} – ${orgLabel}`,
});
}
}
}
return options;
}
export function BackendSelector() {
const { t } = useTranslation("openhands");
const { backends, bundledBackend, active, setActive } =
useActiveBackendContext();
const cloudOrgs = useAllCloudOrganizations();
const currentUserIds = useCloudCurrentUserId();
const { mutateAsync: switchOrg, isPending: isSwitching } =
useSwitchCloudOrganization();
const navigate = useNavigate();
const conversationMatch = useMatch("/conversations/:conversationId");
const bundledLabel = t(I18nKey.BACKEND$LOCAL_ROW);
const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE);
const options = React.useMemo(
() =>
buildOptions(
bundledBackend,
backends,
bundledLabel,
personalWorkspaceLabel,
cloudOrgs,
currentUserIds,
),
[
bundledBackend,
backends,
bundledLabel,
personalWorkspaceLabel,
cloudOrgs,
currentUserIds,
],
);
const activeValue = makeOptionValue(active.backend.id, active.orgId);
const activeOption = options.find((o) => o.value === activeValue);
const someCloudLoading = Object.values(cloudOrgs).some((c) => c.isLoading);
// Self-heal a malformed `(cloudBackendId, null)` selection.
//
// Once a cloud backend's orgs resolve, the dropdown only renders
// per-org rows for it — the `(backendId, null)` row disappears, so
// selecting that shape would drift from what the dropdown can render
// (UI says "Local", APIs hit cloud). When we detect the drift, snap
// the selection onto the personal-workspace org (or, lacking a /me
// result, the first org). Pre-switch the SaaS-side current_org BEFORE
// touching active state so queries refetch (via key change) only
// once and against the correct org context.
React.useEffect(() => {
if (active.backend.kind !== "cloud" || active.orgId) return;
const { backend } = active;
const entry = cloudOrgs[backend.id];
if (!entry || entry.orgs.length === 0) return;
const userId = currentUserIds[backend.id]?.userId ?? null;
const personal = userId
? entry.orgs.find((o) => o.id === userId)
: undefined;
const target = personal ?? entry.orgs[0];
if (!target) return;
switchOrg({ orgId: target.id, backend })
.then(() => setActive(backend.id, target.id))
.catch(() => {
// Error is surfaced by the mutation cache's global handler.
});
}, [active, cloudOrgs, currentUserIds, setActive, switchOrg]);
return (
<Dropdown
testId="backend-selector"
key={`${activeValue}-${activeOption?.label ?? ""}`}
defaultValue={activeOption ?? { value: activeValue, label: bundledLabel }}
onChange={async (item) => {
if (!item || item.value === activeValue) return;
const { backendId, orgId } = parseOptionValue(item.value);
const target = backends.find((b) => b.id === backendId);
// Cloud + org pick: fire `/switch` FIRST against the explicit
// target backend, then update the active selection after it
// resolves. This ensures the SaaS-side `current_org_id` is
// already in place before any of our backend-keyed queries
// refetch — they fire exactly once, with the correct context.
//
// We use `mutateAsync` + `await` (rather than `mutate(... ,
// { onSuccess })`) because per-call onSuccess callbacks were
// observed not to run reliably for this hook in practice; the
// promise-based shape is unambiguous.
if (orgId && target?.kind === "cloud") {
try {
await switchOrg({ orgId, backend: target });
} catch {
// Error is surfaced by the mutation cache's global handler.
return;
}
}
// Pure backend swap (local-↔-bundled or backend-only cloud
// selection without an org) skips `/switch` and updates active
// directly; cloud-with-org falls through here after `/switch`.
setActive(backendId, orgId);
// The current conversation belongs to the previous backend
// and is no longer reachable under the new one — redirect home
// so the user lands on a coherent screen.
if (conversationMatch) navigate("/");
}}
placeholder={bundledLabel}
loading={someCloudLoading || isSwitching}
options={options}
className="bg-[#1F1F1F66] border-[#242424]"
/>
);
}

Some files were not shown because too many files have changed in this diff Show More