SaylorTwift HF Staff commited on
Commit
3d700dd
·
verified ·
1 Parent(s): 63522a5

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/api/acp-service/acp-service.api.ts +110 -0
  2. src/api/agent-profiles-service/agent-profiles-service.api.ts +130 -0
  3. src/api/agent-profiles-service/profile-field-support.ts +41 -0
  4. src/api/automation-service/automation-service.api.test.ts +458 -0
  5. src/api/automation-service/automation-service.api.ts +794 -0
  6. src/api/backend-registry/active-store.ts +193 -0
  7. src/api/backend-registry/auth.ts +18 -0
  8. src/api/backend-registry/default-backend.ts +70 -0
  9. src/api/backend-registry/health-storage.ts +82 -0
  10. src/api/backend-registry/health-store.ts +99 -0
  11. src/api/backend-registry/last-conversation-store.ts +76 -0
  12. src/api/backend-registry/storage.ts +244 -0
  13. src/api/backend-registry/types.ts +23 -0
  14. src/api/backend-registry/url-selection.ts +83 -0
  15. src/api/bash-service/bash-service.api.ts +121 -0
  16. src/api/cloud/agent-profiles-service.api.ts +98 -0
  17. src/api/cloud/client.ts +63 -0
  18. src/api/cloud/conversation-service.api.ts +336 -0
  19. src/api/cloud/git-service.api.ts +104 -0
  20. src/api/cloud/organization-service.api.ts +147 -0
  21. src/api/cloud/profiles-service.api.ts +140 -0
  22. src/api/cloud/provider-connections-service.api.ts +99 -0
  23. src/api/cloud/proxy.ts +39 -0
  24. src/api/cloud/sandbox-service.api.ts +39 -0
  25. src/api/cloud/sandbox-service.types.ts +21 -0
  26. src/api/cloud/secrets-service.api.ts +118 -0
  27. src/api/cloud/settings-service.api.ts +230 -0
  28. src/api/cloud/skills-service.api.ts +83 -0
  29. src/api/cloud/types.ts +56 -0
  30. src/api/config-service/config-service.api.ts +195 -0
  31. src/api/config-service/config-service.types.ts +50 -0
  32. src/api/conversation-service/agent-server-conversation-service.api.ts +1145 -0
  33. src/api/conversation-service/agent-server-conversation-service.types.ts +298 -0
  34. src/api/conversation-service/conversation-service.api.ts +77 -0
  35. src/api/event-service/event-service.api.test.ts +409 -0
  36. src/api/event-service/event-service.api.ts +184 -0
  37. src/api/event-service/event-service.types.ts +34 -0
  38. src/api/git-service/agent-server-git-service.api.ts +282 -0
  39. src/api/git-service/git-service.api.ts +178 -0
  40. src/api/mcp-health/mcp-health-store.ts +79 -0
  41. src/api/mcp-health/probe-mcp-server-health.ts +135 -0
  42. src/api/mcp-service/mcp-redacted-credentials.test.ts +308 -0
  43. src/api/mcp-service/mcp-redacted-credentials.ts +138 -0
  44. src/api/mcp-service/mcp-service.api.test.ts +238 -0
  45. src/api/mcp-service/mcp-service.api.ts +344 -0
  46. src/api/option-service/option-service.api.ts +48 -0
  47. src/api/option-service/option.types.ts +33 -0
  48. src/api/profiles-service/profiles-service.api.ts +191 -0
  49. src/api/provider-connections-service/provider-connections-service.api.test.ts +130 -0
  50. src/api/provider-connections-service/provider-connections-service.api.ts +130 -0
src/api/acp-service/acp-service.api.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BashClient } from "@openhands/typescript-client/clients";
2
+ import type { BashOutput } from "@openhands/typescript-client";
3
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
4
+
5
+ export type AcpAuthStatus = "authenticated" | "unauthenticated" | "unknown";
6
+
7
+ // Hard cap (seconds) for a single detection command. These are fast local
8
+ // status checks (no npx download), so 10s is ample for a healthy CLI while
9
+ // keeping the onboarding spinner short before falling back to "unknown".
10
+ const PROBE_TIMEOUT_SECONDS = 10;
11
+
12
+ interface AcpAuthProbe {
13
+ /** Shell command run on the (local) agent-server host to detect login. */
14
+ command: string;
15
+ /** Classify the command's output into an auth status. */
16
+ classify: (out: BashOutput) => AcpAuthStatus;
17
+ }
18
+
19
+ /** Combined stdout+stderr — CLIs are inconsistent about which stream they use
20
+ * (e.g. ``codex login status`` writes to stderr). */
21
+ function streams(out: BashOutput): string {
22
+ return `${out.stdout ?? ""}\n${out.stderr ?? ""}`;
23
+ }
24
+
25
+ // Claude Code: ``claude auth status --json`` prints {"loggedIn": bool, …}. The
26
+ // CLI exits non-zero when logged out, so we read the JSON, not the exit code.
27
+ // No parseable ``loggedIn`` (e.g. the CLI isn't installed → "command not
28
+ // found", empty stdout) ⇒ unknown, so onboarding shows the API-key fields
29
+ // rather than guessing.
30
+ function classifyClaude(out: BashOutput): AcpAuthStatus {
31
+ let parsed: unknown;
32
+ try {
33
+ parsed = JSON.parse((out.stdout ?? "").trim());
34
+ } catch {
35
+ return "unknown";
36
+ }
37
+ const loggedIn = (parsed as { loggedIn?: unknown } | null)?.loggedIn;
38
+ if (typeof loggedIn === "boolean") {
39
+ return loggedIn ? "authenticated" : "unauthenticated";
40
+ }
41
+ return "unknown";
42
+ }
43
+
44
+ // Codex: ``codex login status`` prints "Logged in using …" / "Not logged in"
45
+ // (to stderr), so check both streams. Match "not logged in" first since it
46
+ // contains the "logged in" substring. Neither phrase (e.g. CLI missing) ⇒
47
+ // unknown.
48
+ function classifyCodex(out: BashOutput): AcpAuthStatus {
49
+ const text = streams(out).toLowerCase();
50
+ if (text.includes("not logged in")) return "unauthenticated";
51
+ if (text.includes("logged in")) return "authenticated";
52
+ return "unknown";
53
+ }
54
+
55
+ // Gemini CLI signs in via Google OAuth and has no status command, so we check
56
+ // its credentials file. The command echoes present/absent and exits 0 either
57
+ // way; anything else (a shell failure) ⇒ unknown.
58
+ function classifyGemini(out: BashOutput): AcpAuthStatus {
59
+ // The command echoes exactly `present` / `absent` to stdout, so match trimmed
60
+ // stdout exactly. Reading only stdout (not stderr) means a stray shell
61
+ // warning can't turn a real result into `unknown`.
62
+ const text = (out.stdout ?? "").trim();
63
+ if (text === "present") return "authenticated";
64
+ if (text === "absent") return "unauthenticated";
65
+ return "unknown";
66
+ }
67
+
68
+ // Per-provider login detection, keyed by ``acp_server`` / OnboardingAgentId.
69
+ // Providers absent here (OpenHands, custom, unknown) report ``unknown``.
70
+ const ACP_AUTH_PROBES: Record<string, AcpAuthProbe> = {
71
+ "claude-code": {
72
+ command: "claude auth status --json",
73
+ classify: classifyClaude,
74
+ },
75
+ codex: {
76
+ command: "codex login status",
77
+ classify: classifyCodex,
78
+ },
79
+ "gemini-cli": {
80
+ command:
81
+ 'test -f "$HOME/.gemini/oauth_creds.json" && echo present || echo absent',
82
+ classify: classifyGemini,
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Detects whether the selected ACP provider is already logged in — entirely
88
+ * client-side, with **no dedicated agent-server endpoint**. It runs the
89
+ * provider's own status command (or, for Gemini, a credentials-file check)
90
+ * through the existing agent-server bash endpoint and classifies the output.
91
+ *
92
+ * Gated by the caller to **local backends**: the command runs wherever the
93
+ * agent-server runs, and on a user's own machine the provider CLIs and
94
+ * credential files live at their standard paths. No prompt is sent, so no model
95
+ * tokens are spent. A provider that can't be classified — CLI not installed,
96
+ * unexpected output, or the bash call failing — comes back as ``unknown`` so
97
+ * onboarding falls back to the API-key fields rather than a misleading banner.
98
+ */
99
+ class AcpService {
100
+ static async getAuthStatus(server: string): Promise<AcpAuthStatus> {
101
+ const probe = ACP_AUTH_PROBES[server];
102
+ if (!probe) return "unknown";
103
+ const out = await new BashClient(
104
+ getAgentServerClientOptions(),
105
+ ).executeCommand(probe.command, undefined, PROBE_TIMEOUT_SECONDS);
106
+ return probe.classify(out);
107
+ }
108
+ }
109
+
110
+ export default AcpService;
src/api/agent-profiles-service/agent-profiles-service.api.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AgentProfilesService wraps the SDK's AgentProfilesClient, creating a client
3
+ * per-call so it always picks up the active backend's host/apiKey (same pattern
4
+ * as ProfilesService / SettingsService).
5
+ *
6
+ * Backs the Settings → Agent profiles library + reused agent editor. The
7
+ * `AgentProfilesClient` ships in ts-client (pinned 1.28.0 here); the
8
+ * `/api/agent-profiles` endpoints it targets shipped in agent-server v1.29.0
9
+ * (local) and in the enterprise cloud app-server (OpenHands #15060, epic
10
+ * #3730). Cloud exposes the SAME contract but authenticates with a bearer
11
+ * token + `X-Org-Id`, so cloud calls route through `callCloudProxy` (see
12
+ * `cloud/agent-profiles-service.api.ts`) instead of the direct client.
13
+ */
14
+ import {
15
+ AgentProfilesClient,
16
+ type GetAgentProfileOptions,
17
+ } from "@openhands/typescript-client/clients";
18
+ import type {
19
+ AgentProfile,
20
+ AgentProfileSummary,
21
+ AgentProfileSaveInput,
22
+ AgentProfileListResponse,
23
+ AgentProfileDetailResponse,
24
+ AgentProfileMutationResponse,
25
+ ActivateAgentProfileResponse,
26
+ ExposeSecretsMode,
27
+ } from "@openhands/typescript-client";
28
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
29
+ import { getActiveBackend } from "../backend-registry/active-store";
30
+ import {
31
+ listCloudAgentProfiles,
32
+ getCloudAgentProfile,
33
+ saveCloudAgentProfile,
34
+ deleteCloudAgentProfile,
35
+ renameCloudAgentProfile,
36
+ activateCloudAgentProfile,
37
+ } from "../cloud/agent-profiles-service.api";
38
+
39
+ function isCloud(): boolean {
40
+ return getActiveBackend().backend.kind === "cloud";
41
+ }
42
+
43
+ /**
44
+ * The seeded, well-known baseline agent profile. The backend lazily seeds it to
45
+ * mirror the user's global config (#3719), and onboarding configures it from the
46
+ * user's choice. It stands in for global `agent_settings`, so the home-launch
47
+ * path treats it as the enriched baseline rather than a deliberate profile
48
+ * selection (see `useCreateConversation`).
49
+ */
50
+ export const WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME = "default";
51
+
52
+ // Re-export SDK types for consumers.
53
+ export type {
54
+ AgentProfile,
55
+ AgentProfileSummary,
56
+ AgentProfileSaveInput,
57
+ AgentProfileListResponse,
58
+ AgentProfileDetailResponse,
59
+ AgentProfileMutationResponse,
60
+ ActivateAgentProfileResponse,
61
+ };
62
+
63
+ class AgentProfilesService {
64
+ static async listProfiles(): Promise<AgentProfileListResponse> {
65
+ if (isCloud()) return listCloudAgentProfiles();
66
+ return new AgentProfilesClient(
67
+ getAgentServerClientOptions(),
68
+ ).listAgentProfiles();
69
+ }
70
+
71
+ static async getProfile(
72
+ name: string,
73
+ exposeSecrets?: ExposeSecretsMode,
74
+ ): Promise<AgentProfileDetailResponse> {
75
+ // Agent profiles are secret-free at rest now (embedded skills and their
76
+ // `mcp_tools` were removed in #4017 — a profile carries only refs + the
77
+ // `disabled_skills` deny-list of names), so `exposeSecrets` is vestigial and
78
+ // cloud ignores it; kept for local signature parity with ProfilesService.
79
+ if (isCloud()) return getCloudAgentProfile(name);
80
+ const options: GetAgentProfileOptions = exposeSecrets
81
+ ? { exposeSecrets }
82
+ : {};
83
+ return new AgentProfilesClient(
84
+ getAgentServerClientOptions(),
85
+ ).getAgentProfile(name, options);
86
+ }
87
+
88
+ /** Create or overwrite a profile by name (upsert). */
89
+ static async saveProfile(
90
+ name: string,
91
+ profile: AgentProfileSaveInput,
92
+ ): Promise<AgentProfileMutationResponse> {
93
+ if (isCloud()) return saveCloudAgentProfile(name, profile);
94
+ return new AgentProfilesClient(
95
+ getAgentServerClientOptions(),
96
+ ).saveAgentProfile(name, profile);
97
+ }
98
+
99
+ static async deleteProfile(
100
+ name: string,
101
+ ): Promise<AgentProfileMutationResponse> {
102
+ if (isCloud()) return deleteCloudAgentProfile(name);
103
+ return new AgentProfilesClient(
104
+ getAgentServerClientOptions(),
105
+ ).deleteAgentProfile(name);
106
+ }
107
+
108
+ static async renameProfile(
109
+ name: string,
110
+ newName: string,
111
+ ): Promise<AgentProfileMutationResponse> {
112
+ if (isCloud()) return renameCloudAgentProfile(name, newName);
113
+ return new AgentProfilesClient(
114
+ getAgentServerClientOptions(),
115
+ ).renameAgentProfile(name, newName);
116
+ }
117
+
118
+ /** Activate by the profile's stable UUID `id` (pointer-only; never writes
119
+ * agent_settings). */
120
+ static async activateProfile(
121
+ profileId: string,
122
+ ): Promise<ActivateAgentProfileResponse> {
123
+ if (isCloud()) return activateCloudAgentProfile(profileId);
124
+ return new AgentProfilesClient(
125
+ getAgentServerClientOptions(),
126
+ ).activateAgentProfile(profileId);
127
+ }
128
+ }
129
+
130
+ export default AgentProfilesService;
src/api/agent-profiles-service/profile-field-support.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ compareAgentServerVersions,
3
+ getCachedAgentServerVersion,
4
+ } from "#/api/agent-server-compatibility";
5
+
6
+ /**
7
+ * `enable_switch_llm_tool` reached `OpenHandsAgentProfile` in
8
+ * software-agent-sdk#3870, first released in agent-server 1.31.0. The same
9
+ * field reached `AgentSettingsConfig` far earlier (1.22.0), so the agent
10
+ * settings schema advertises it on servers whose *profile* model still
11
+ * rejects it.
12
+ *
13
+ * That gap sits inside the supported range rather than below it: agent
14
+ * profiles shipped in 1.29.0 and Canvas accepts back to
15
+ * `config/defaults.json`'s `compatibility.minimumAgentServer`. On 1.29.0
16
+ * through 1.30.x, `AgentProfileBase` is `extra="forbid"`, so a profile save
17
+ * carrying the key 422s and the whole save is lost. Hence the profile editor
18
+ * gates on the server version instead of reusing the settings schema's
19
+ * presence check, which answers a different question.
20
+ */
21
+ export const MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL = "1.31.0";
22
+
23
+ /**
24
+ * Whether the active backend's agent-profile model accepts
25
+ * `enable_switch_llm_tool`.
26
+ *
27
+ * An unreadable version counts as support, matching the other version-gated
28
+ * call sites: cloud backends expose no `/server_info` to probe and track the
29
+ * current contract, and a local backend whose version does not parse never
30
+ * gets past `assertAgentServerVersionIsSupported` at boot.
31
+ */
32
+ export function agentProfileSupportsSwitchLlmTool(): boolean {
33
+ const version = getCachedAgentServerVersion();
34
+ if (!version) return true;
35
+ const comparison = compareAgentServerVersions(
36
+ version,
37
+ MIN_AGENT_SERVER_VERSION_FOR_PROFILE_SWITCH_LLM_TOOL,
38
+ );
39
+ if (comparison === null) return true;
40
+ return comparison >= 0;
41
+ }
src/api/automation-service/automation-service.api.test.ts ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ setActiveSelection,
4
+ setRegisteredBackends,
5
+ } from "#/api/backend-registry/active-store";
6
+ import type { Backend } from "#/api/backend-registry/types";
7
+ import type { Automation, AutomationSpec } from "#/types/automation";
8
+ import type { GitSyncStatus } from "#/types/git-sync";
9
+ import AutomationService from "./automation-service.api";
10
+
11
+ const {
12
+ localAxios,
13
+ callCloudProxy,
14
+ clearPendingLocalTelemetryRevocation,
15
+ getTelemetryConsent,
16
+ getTelemetryDistinctId,
17
+ getTelemetryDistinctIdForConsentSync,
18
+ } = vi.hoisted(() => ({
19
+ localAxios: {
20
+ interceptors: { request: { use: vi.fn() } },
21
+ get: vi.fn(),
22
+ post: vi.fn(),
23
+ put: vi.fn(),
24
+ patch: vi.fn(),
25
+ delete: vi.fn(),
26
+ },
27
+ callCloudProxy: vi.fn(),
28
+ clearPendingLocalTelemetryRevocation: vi.fn(),
29
+ getTelemetryConsent: vi.fn(),
30
+ getTelemetryDistinctId: vi.fn(),
31
+ getTelemetryDistinctIdForConsentSync: vi.fn(),
32
+ }));
33
+
34
+ vi.mock("axios", () => ({
35
+ default: {
36
+ create: () => localAxios,
37
+ post: vi.fn(),
38
+ },
39
+ }));
40
+
41
+ vi.mock("#/api/cloud/proxy", () => ({
42
+ callCloudProxy,
43
+ }));
44
+
45
+ vi.mock("#/services/telemetry", () => ({
46
+ clearPendingLocalTelemetryRevocation,
47
+ getTelemetryConsent,
48
+ getTelemetryDistinctId,
49
+ getTelemetryDistinctIdForConsentSync,
50
+ }));
51
+
52
+ const localBackend: Backend = {
53
+ id: "local-test",
54
+ name: "Local test backend",
55
+ host: "http://localhost:3000",
56
+ apiKey: "test-session-key",
57
+ kind: "local",
58
+ };
59
+
60
+ const cloudBackend: Backend = {
61
+ id: "cloud-test",
62
+ name: "Cloud test backend",
63
+ host: "https://app.example.test",
64
+ apiKey: "cloud-api-key",
65
+ kind: "cloud",
66
+ };
67
+
68
+ const spec: AutomationSpec = {
69
+ name: "Imported review",
70
+ prompt: "Review open pull requests.",
71
+ trigger: {
72
+ type: "cron",
73
+ schedule: "0 9 * * *",
74
+ schedule_human: "Daily at 09:00",
75
+ },
76
+ enabled: true,
77
+ repository: "openhands/agent-canvas",
78
+ branch: "main",
79
+ plugins: ["github:openhands/extensions"],
80
+ model: "fast",
81
+ timezone: "America/Los_Angeles",
82
+ };
83
+
84
+ const createdAutomation: Automation = {
85
+ id: "created-automation",
86
+ name: spec.name,
87
+ prompt: spec.prompt,
88
+ trigger: { type: "cron", schedule: spec.trigger.schedule },
89
+ enabled: true,
90
+ model: spec.model,
91
+ created_at: "2026-07-10T00:00:00Z",
92
+ updated_at: "2026-07-10T00:00:00Z",
93
+ };
94
+
95
+ describe("AutomationService.getSdkVersion", () => {
96
+ beforeEach(() => {
97
+ setRegisteredBackends([localBackend]);
98
+ setActiveSelection({ backendId: localBackend.id });
99
+ });
100
+
101
+ afterEach(() => {
102
+ setActiveSelection(null);
103
+ setRegisteredBackends([]);
104
+ vi.clearAllMocks();
105
+ });
106
+
107
+ it("fetches the local automation SDK version from the automation sidecar", async () => {
108
+ localAxios.get.mockResolvedValueOnce({ data: { sdk_version: "1.36.1" } });
109
+
110
+ await expect(AutomationService.getSdkVersion()).resolves.toBe("1.36.1");
111
+
112
+ expect(localAxios.get).toHaveBeenCalledWith("/api/automation/sdk-version", {
113
+ timeout: 5000,
114
+ });
115
+ });
116
+
117
+ it("fetches the cloud automation SDK version through the cloud proxy", async () => {
118
+ setRegisteredBackends([cloudBackend]);
119
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
120
+ callCloudProxy.mockResolvedValueOnce({ sdk_version: "1.36.2" });
121
+
122
+ await expect(AutomationService.getSdkVersion()).resolves.toBe("1.36.2");
123
+
124
+ expect(callCloudProxy).toHaveBeenCalledWith(
125
+ expect.objectContaining({
126
+ backend: cloudBackend,
127
+ method: "GET",
128
+ path: "/api/automation/sdk-version",
129
+ headers: expect.objectContaining({ "X-Org-Id": "org-1" }),
130
+ timeoutSeconds: 5,
131
+ }),
132
+ );
133
+ });
134
+
135
+ it("returns null when the SDK version endpoint is unavailable", async () => {
136
+ localAxios.get.mockRejectedValueOnce(new Error("not running"));
137
+
138
+ await expect(AutomationService.getSdkVersion()).resolves.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("AutomationService.syncTelemetryConsent", () => {
143
+ beforeEach(() => {
144
+ setRegisteredBackends([localBackend]);
145
+ setActiveSelection({ backendId: localBackend.id });
146
+ getTelemetryConsent.mockReturnValue("pending");
147
+ getTelemetryDistinctIdForConsentSync.mockResolvedValue("ph-fe-sync");
148
+ localAxios.post.mockResolvedValue({ data: { consent_granted: true } });
149
+ });
150
+
151
+ afterEach(() => {
152
+ setActiveSelection(null);
153
+ setRegisteredBackends([]);
154
+ vi.clearAllMocks();
155
+ });
156
+
157
+ it("posts local telemetry consent with the frontend PostHog distinct ID", async () => {
158
+ await AutomationService.syncTelemetryConsent("granted");
159
+
160
+ expect(localAxios.post).toHaveBeenCalledWith(
161
+ "/api/automation/v1/telemetry/consent",
162
+ {
163
+ consent_granted: true,
164
+ frontend_distinct_id: "ph-fe-sync",
165
+ },
166
+ { timeout: 5000 },
167
+ );
168
+ });
169
+
170
+ it("uses the current telemetry consent when no explicit value is supplied", async () => {
171
+ getTelemetryConsent.mockReturnValue("denied");
172
+
173
+ await AutomationService.syncTelemetryConsent();
174
+
175
+ expect(localAxios.post).toHaveBeenCalledWith(
176
+ "/api/automation/v1/telemetry/consent",
177
+ {
178
+ consent_granted: false,
179
+ frontend_distinct_id: "ph-fe-sync",
180
+ },
181
+ { timeout: 5000 },
182
+ );
183
+ expect(clearPendingLocalTelemetryRevocation).toHaveBeenCalledWith(
184
+ "ph-fe-sync",
185
+ );
186
+ });
187
+
188
+ it("skips cloud backends because cloud consent is handled by auth", async () => {
189
+ setRegisteredBackends([cloudBackend]);
190
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
191
+
192
+ await AutomationService.syncTelemetryConsent("granted");
193
+
194
+ expect(localAxios.post).not.toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ describe("AutomationService.createAutomation", () => {
199
+ beforeEach(() => {
200
+ setRegisteredBackends([localBackend]);
201
+ setActiveSelection({ backendId: localBackend.id });
202
+ localAxios.post.mockResolvedValue({ data: createdAutomation });
203
+ localAxios.patch.mockImplementation(
204
+ async (_path: string, body: Partial<Automation>) => ({
205
+ data: { ...createdAutomation, ...body },
206
+ }),
207
+ );
208
+ });
209
+
210
+ afterEach(() => {
211
+ setActiveSelection(null);
212
+ setRegisteredBackends([]);
213
+ vi.clearAllMocks();
214
+ });
215
+
216
+ it("creates plugin automations through the preset API and disables them", async () => {
217
+ const created = await AutomationService.createAutomation(spec);
218
+
219
+ expect(localAxios.post).toHaveBeenCalledWith(
220
+ "/api/automation/v1/preset/plugin",
221
+ {
222
+ name: spec.name,
223
+ prompt: spec.prompt,
224
+ model: spec.model,
225
+ trigger: {
226
+ type: "event",
227
+ source: "agent-canvas-import",
228
+ on: expect.stringMatching(/^pending\./),
229
+ },
230
+ repos: [
231
+ {
232
+ url: spec.repository,
233
+ ref: spec.branch,
234
+ provider: "github",
235
+ },
236
+ ],
237
+ plugins: [{ source: spec.plugins![0] }],
238
+ },
239
+ {
240
+ baseURL: localBackend.host,
241
+ headers: expect.objectContaining({
242
+ "X-Session-API-Key": localBackend.apiKey,
243
+ }),
244
+ },
245
+ );
246
+ expect(localAxios.patch).toHaveBeenCalledWith(
247
+ "/api/automation/v1/created-automation",
248
+ {
249
+ trigger: {
250
+ type: "cron",
251
+ schedule: spec.trigger.schedule,
252
+ timezone: spec.timezone,
253
+ },
254
+ enabled: false,
255
+ },
256
+ {
257
+ baseURL: localBackend.host,
258
+ headers: expect.objectContaining({
259
+ "X-Session-API-Key": localBackend.apiKey,
260
+ }),
261
+ },
262
+ );
263
+ expect(created.enabled).toBe(false);
264
+ });
265
+
266
+ it("uses the prompt preset path when no plugins are configured", async () => {
267
+ await AutomationService.createAutomation({
268
+ ...spec,
269
+ plugins: undefined,
270
+ });
271
+
272
+ expect(localAxios.post).toHaveBeenCalledWith(
273
+ "/api/automation/v1/preset/prompt",
274
+ expect.not.objectContaining({ plugins: expect.anything() }),
275
+ expect.any(Object),
276
+ );
277
+ });
278
+
279
+ it("applies the imported event trigger while disabling the automation", async () => {
280
+ const eventTrigger = {
281
+ type: "event",
282
+ source: "github",
283
+ on: ["pull_request.opened", "pull_request.synchronize"],
284
+ filter: "repository.full_name == 'openhands/agent-canvas'",
285
+ };
286
+
287
+ await AutomationService.createAutomation({
288
+ ...spec,
289
+ trigger: eventTrigger,
290
+ });
291
+
292
+ expect(localAxios.patch).toHaveBeenCalledWith(
293
+ "/api/automation/v1/created-automation",
294
+ { trigger: eventTrigger, enabled: false },
295
+ expect.any(Object),
296
+ );
297
+ });
298
+
299
+ it("uses the selected cloud backend and organization for both requests", async () => {
300
+ setRegisteredBackends([cloudBackend]);
301
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
302
+ callCloudProxy
303
+ .mockResolvedValueOnce(createdAutomation)
304
+ .mockResolvedValueOnce({ ...createdAutomation, enabled: false });
305
+
306
+ const created = await AutomationService.createAutomation(spec);
307
+
308
+ expect(callCloudProxy).toHaveBeenNthCalledWith(
309
+ 1,
310
+ expect.objectContaining({
311
+ backend: cloudBackend,
312
+ method: "POST",
313
+ path: "/api/automation/v1/preset/plugin",
314
+ body: expect.objectContaining({ name: spec.name }),
315
+ headers: expect.objectContaining({ "X-Org-Id": "org-1" }),
316
+ }),
317
+ );
318
+ expect(callCloudProxy).toHaveBeenNthCalledWith(
319
+ 2,
320
+ expect.objectContaining({
321
+ backend: cloudBackend,
322
+ method: "PATCH",
323
+ path: "/api/automation/v1/created-automation",
324
+ body: expect.objectContaining({ enabled: false }),
325
+ headers: expect.objectContaining({ "X-Org-Id": "org-1" }),
326
+ }),
327
+ );
328
+ expect(created.enabled).toBe(false);
329
+ });
330
+
331
+ it("removes the inert automation when disabling it fails", async () => {
332
+ const updateError = new Error("update failed");
333
+ localAxios.patch.mockRejectedValueOnce(updateError);
334
+
335
+ await expect(AutomationService.createAutomation(spec)).rejects.toBe(
336
+ updateError,
337
+ );
338
+
339
+ expect(localAxios.delete).toHaveBeenCalledWith(
340
+ "/api/automation/v1/created-automation",
341
+ {
342
+ baseURL: localBackend.host,
343
+ headers: expect.objectContaining({
344
+ "X-Session-API-Key": localBackend.apiKey,
345
+ }),
346
+ },
347
+ );
348
+ });
349
+
350
+ it("includes the timeout in the create request when the spec sets one", async () => {
351
+ await AutomationService.createAutomation({ ...spec, timeout: 1200 });
352
+
353
+ expect(localAxios.post).toHaveBeenCalledWith(
354
+ "/api/automation/v1/preset/plugin",
355
+ expect.objectContaining({ timeout: 1200 }),
356
+ expect.any(Object),
357
+ );
358
+ });
359
+ });
360
+
361
+ const gitSyncStatus: GitSyncStatus = {
362
+ enabled: true,
363
+ repo_url: "https://example.com/org/repo.git",
364
+ branch: "main",
365
+ path: "automations",
366
+ encryption_enabled: false,
367
+ interval_seconds: 0,
368
+ last_synced_commit: "abc123",
369
+ last_synced_at: "2026-07-10T00:00:00Z",
370
+ last_error: null,
371
+ last_error_at: null,
372
+ dirty_count: 0,
373
+ };
374
+
375
+ describe("AutomationService git sync", () => {
376
+ beforeEach(() => {
377
+ setRegisteredBackends([localBackend]);
378
+ setActiveSelection({ backendId: localBackend.id });
379
+ });
380
+
381
+ afterEach(() => {
382
+ setActiveSelection(null);
383
+ setRegisteredBackends([]);
384
+ vi.clearAllMocks();
385
+ });
386
+
387
+ it("fetches status from the local automation backend", async () => {
388
+ localAxios.get.mockResolvedValueOnce({ data: gitSyncStatus });
389
+
390
+ await expect(AutomationService.getGitSyncStatus()).resolves.toEqual(
391
+ gitSyncStatus,
392
+ );
393
+
394
+ expect(localAxios.get).toHaveBeenCalledWith(
395
+ "/api/automation/v1/git-sync/status",
396
+ );
397
+ });
398
+
399
+ it("fetches status through the cloud proxy", async () => {
400
+ setRegisteredBackends([cloudBackend]);
401
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
402
+ callCloudProxy.mockResolvedValueOnce(gitSyncStatus);
403
+
404
+ await expect(AutomationService.getGitSyncStatus()).resolves.toEqual(
405
+ gitSyncStatus,
406
+ );
407
+
408
+ expect(callCloudProxy).toHaveBeenCalledWith(
409
+ expect.objectContaining({
410
+ backend: cloudBackend,
411
+ method: "GET",
412
+ path: "/api/automation/v1/git-sync/status",
413
+ }),
414
+ );
415
+ });
416
+
417
+ it("sends a partial config update", async () => {
418
+ localAxios.put.mockResolvedValueOnce({ data: gitSyncStatus });
419
+
420
+ await expect(
421
+ AutomationService.updateGitSyncConfig({ branch: "develop" }),
422
+ ).resolves.toEqual(gitSyncStatus);
423
+
424
+ expect(localAxios.put).toHaveBeenCalledWith(
425
+ "/api/automation/v1/git-sync/config",
426
+ { branch: "develop" },
427
+ );
428
+ });
429
+
430
+ it("sends a null override through the cloud proxy", async () => {
431
+ setRegisteredBackends([cloudBackend]);
432
+ setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
433
+ callCloudProxy.mockResolvedValueOnce(gitSyncStatus);
434
+
435
+ await AutomationService.updateGitSyncConfig({ token: null });
436
+
437
+ expect(callCloudProxy).toHaveBeenCalledWith(
438
+ expect.objectContaining({
439
+ backend: cloudBackend,
440
+ method: "PUT",
441
+ path: "/api/automation/v1/git-sync/config",
442
+ body: { token: null },
443
+ }),
444
+ );
445
+ });
446
+
447
+ it("triggers a sync cycle", async () => {
448
+ localAxios.post.mockResolvedValueOnce({ data: { triggered: true } });
449
+
450
+ await expect(AutomationService.triggerGitSync()).resolves.toEqual({
451
+ triggered: true,
452
+ });
453
+
454
+ expect(localAxios.post).toHaveBeenCalledWith(
455
+ "/api/automation/v1/git-sync/sync",
456
+ );
457
+ });
458
+ });
src/api/automation-service/automation-service.api.ts ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from "axios";
2
+ import {
3
+ clearPendingLocalTelemetryRevocation,
4
+ getTelemetryConsent,
5
+ getTelemetryDistinctId,
6
+ getTelemetryDistinctIdForConsentSync,
7
+ type TelemetryConsent,
8
+ } from "#/services/telemetry";
9
+ import type {
10
+ Automation,
11
+ AutomationRun,
12
+ AutomationSpec,
13
+ AutomationTrigger,
14
+ AutomationsResponse,
15
+ AutomationRunsResponse,
16
+ } from "#/types/automation";
17
+ import type {
18
+ GitSyncCheckResponse,
19
+ GitSyncConfigUpdateRequest,
20
+ GitSyncStatus,
21
+ GitSyncTriggerResponse,
22
+ } from "#/types/git-sync";
23
+ import {
24
+ automationCreateEndpoint,
25
+ automationUploadEndpoint,
26
+ } from "#/manifests/automation-setup";
27
+ import {
28
+ getAutomationEndpoint,
29
+ getAutomationIdEndpoint,
30
+ getImportExportSpec,
31
+ } from "#/manifests/automation-interface";
32
+ import type {
33
+ DeploymentCapabilities,
34
+ SetupEntry,
35
+ SetupRequestBody,
36
+ ValidateDraftResponse,
37
+ } from "#/manifests/types";
38
+ import type { Backend, ResolvedActiveBackend } from "../backend-registry/types";
39
+ import {
40
+ getActiveBackend,
41
+ getEffectiveLocalBackend,
42
+ } from "../backend-registry/active-store";
43
+ import { NoBackendAvailableError } from "../agent-server-client-options";
44
+ import { callCloudProxy } from "../cloud/proxy";
45
+ import {
46
+ AGENT_CANVAS_CLIENT_HEADERS,
47
+ OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER,
48
+ } from "../client-source";
49
+
50
+ const AUTOMATION_BASE_PATH = "/api/automation";
51
+
52
+ export interface AutomationHealthResponse {
53
+ status: "ok" | "error";
54
+ message?: string;
55
+ }
56
+
57
+ type AutomationSdkVersionResponse =
58
+ | string
59
+ | {
60
+ sdk_version?: unknown;
61
+ version?: unknown;
62
+ };
63
+
64
+ // Local automation calls go to the automation sidecar that
65
+ // `scripts/dev-with-automation.mjs` mounts behind the local agent-server.
66
+ // Both backends use the same session API key and the same `X-Session-API-Key`
67
+ // header for consistency.
68
+ const localAutomationAxios = axios.create();
69
+
70
+ async function buildAutomationRequestHeaders(
71
+ extraHeaders: Record<string, string> = {},
72
+ ): Promise<Record<string, string>> {
73
+ const telemetryDistinctId = await getTelemetryDistinctId();
74
+ return {
75
+ ...AGENT_CANVAS_CLIENT_HEADERS,
76
+ ...(telemetryDistinctId
77
+ ? { [OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER]: telemetryDistinctId }
78
+ : {}),
79
+ ...extraHeaders,
80
+ };
81
+ }
82
+
83
+ localAutomationAxios.interceptors.request.use(async (config) => {
84
+ const requestHeaders = await buildAutomationRequestHeaders();
85
+ Object.entries(requestHeaders).forEach(([name, value]) => {
86
+ config.headers.set(name, value);
87
+ });
88
+
89
+ // Import uses an explicit baseURL/header pair so its POST, PATCH, and
90
+ // cleanup stay pinned to the backend selected when the mutation started.
91
+ if (config.baseURL) return config;
92
+
93
+ // Resolve the local backend on every call so it tracks the
94
+ // currently-active local backend (and any host/key edits made via the
95
+ // manage-backends UI), rather than freezing whatever value the
96
+ // agent-server-config produced at module load time.
97
+ // Using the backend registry (rather than the build-time VITE_SESSION_API_KEY
98
+ // env var) ensures the published npm package picks up the runtime-injected
99
+ // session key that scripts/static-server.mjs seeds into localStorage, fixing
100
+ // the 401 errors reported in issue #829.
101
+ const backend = getEffectiveLocalBackend();
102
+ if (!backend) throw new NoBackendAvailableError();
103
+ // eslint-disable-next-line no-param-reassign
104
+ config.baseURL = backend.host;
105
+
106
+ const apiKey = backend.apiKey?.trim();
107
+ if (apiKey) {
108
+ config.headers.set("X-Session-API-Key", apiKey);
109
+ }
110
+ return config;
111
+ });
112
+
113
+ function normalizeAutomationSdkVersion(version: unknown): string | null {
114
+ if (typeof version !== "string") return null;
115
+ const trimmed = version.trim();
116
+ return trimmed || null;
117
+ }
118
+
119
+ function getAutomationSdkVersionFromResponse(
120
+ response: AutomationSdkVersionResponse,
121
+ ): string | null {
122
+ if (typeof response === "string") {
123
+ return normalizeAutomationSdkVersion(response);
124
+ }
125
+ return (
126
+ normalizeAutomationSdkVersion(response.sdk_version) ??
127
+ normalizeAutomationSdkVersion(response.version)
128
+ );
129
+ }
130
+
131
+ function buildPaginationQuery(limit: number, offset: number): string {
132
+ const params = new URLSearchParams();
133
+ params.set("limit", String(limit));
134
+ params.set("offset", String(offset));
135
+ return params.toString();
136
+ }
137
+
138
+ function buildImportedTrigger(spec: AutomationSpec): AutomationTrigger {
139
+ if (spec.trigger.type === "event") {
140
+ return {
141
+ type: "event",
142
+ source: spec.trigger.source,
143
+ on: spec.trigger.on,
144
+ ...(spec.trigger.filter && { filter: spec.trigger.filter }),
145
+ };
146
+ }
147
+
148
+ return {
149
+ type: "cron",
150
+ schedule: spec.trigger.schedule,
151
+ timezone: spec.timezone ?? spec.trigger.timezone ?? "UTC",
152
+ };
153
+ }
154
+
155
+ function generatePendingImportEvent(): string {
156
+ if (
157
+ typeof crypto !== "undefined" &&
158
+ typeof crypto.randomUUID === "function"
159
+ ) {
160
+ return `pending.${crypto.randomUUID()}`;
161
+ }
162
+ return `pending.${Date.now()}.${Math.random().toString(36).slice(2, 10)}`;
163
+ }
164
+
165
+ function buildCreateAutomationRequest(spec: AutomationSpec) {
166
+ if (!spec.prompt) {
167
+ throw new Error("An automation prompt is required for import.");
168
+ }
169
+
170
+ const { importDefaults } = getImportExportSpec();
171
+ const repos = spec.repository
172
+ ? [
173
+ {
174
+ url: spec.repository,
175
+ ...(spec.branch && { ref: spec.branch }),
176
+ ...(!spec.repository.includes("://") &&
177
+ !spec.repository.startsWith("git@") && {
178
+ provider: importDefaults.repoProvider,
179
+ }),
180
+ },
181
+ ]
182
+ : undefined;
183
+
184
+ return {
185
+ path: `${AUTOMATION_BASE_PATH}${getAutomationEndpoint(
186
+ spec.plugins?.length ? "createPlugin" : "createPrompt",
187
+ )}`,
188
+ body: {
189
+ name: spec.name,
190
+ prompt: spec.prompt,
191
+ // Preset creation defaults to enabled. A unique event trigger keeps the
192
+ // new record inert until the real trigger and disabled state are applied
193
+ // together in the follow-up PATCH.
194
+ trigger: {
195
+ type: "event",
196
+ source: importDefaults.placeholderEventSource,
197
+ on: generatePendingImportEvent(),
198
+ },
199
+ ...(spec.model && { model: spec.model }),
200
+ ...(repos && { repos }),
201
+ ...(spec.plugins?.length && {
202
+ plugins: spec.plugins.map((source) => ({ source })),
203
+ }),
204
+ ...(spec.timeout != null && { timeout: spec.timeout }),
205
+ },
206
+ };
207
+ }
208
+
209
+ async function buildPinnedLocalConfig(backend: Backend) {
210
+ const apiKey = backend.apiKey.trim();
211
+ return {
212
+ baseURL: backend.host,
213
+ headers: await buildAutomationRequestHeaders(
214
+ apiKey ? { "X-Session-API-Key": apiKey } : {},
215
+ ),
216
+ };
217
+ }
218
+
219
+ async function buildPinnedCloudHeaders(active: ResolvedActiveBackend) {
220
+ return buildAutomationRequestHeaders(
221
+ active.orgId ? { "X-Org-Id": active.orgId } : {},
222
+ );
223
+ }
224
+
225
+ class AutomationService {
226
+ static async syncTelemetryConsent(
227
+ consent: TelemetryConsent = getTelemetryConsent(),
228
+ ): Promise<void> {
229
+ const active = getActiveBackend();
230
+ if (active.backend.kind !== "local") return;
231
+
232
+ const frontendDistinctId = await getTelemetryDistinctIdForConsentSync();
233
+ await localAutomationAxios.post(
234
+ `${AUTOMATION_BASE_PATH}/v1/telemetry/consent`,
235
+ {
236
+ consent_granted: consent === "granted",
237
+ frontend_distinct_id: frontendDistinctId,
238
+ },
239
+ { timeout: 5000 },
240
+ );
241
+ if (consent !== "granted" && frontendDistinctId) {
242
+ clearPendingLocalTelemetryRevocation(frontendDistinctId);
243
+ }
244
+ }
245
+
246
+ static async getSdkVersion(): Promise<string | null> {
247
+ const active = getActiveBackend();
248
+ const path = `${AUTOMATION_BASE_PATH}/sdk-version`;
249
+
250
+ try {
251
+ let response: AutomationSdkVersionResponse;
252
+ if (active.backend.kind === "cloud") {
253
+ response = await callCloudProxy<AutomationSdkVersionResponse>({
254
+ backend: active.backend,
255
+ method: "GET",
256
+ path,
257
+ headers: await buildPinnedCloudHeaders(active),
258
+ timeoutSeconds: 5,
259
+ });
260
+ } else {
261
+ const { data } =
262
+ await localAutomationAxios.get<AutomationSdkVersionResponse>(path, {
263
+ timeout: 5000,
264
+ });
265
+ response = data;
266
+ }
267
+
268
+ return getAutomationSdkVersionFromResponse(response);
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ static async listAutomations(
275
+ params: { limit?: number; offset?: number } = {},
276
+ ): Promise<AutomationsResponse> {
277
+ const { limit = 50, offset = 0 } = params;
278
+ const active = getActiveBackend().backend;
279
+
280
+ if (active.kind === "cloud") {
281
+ return callCloudProxy<AutomationsResponse>({
282
+ backend: active,
283
+ method: "GET",
284
+ path: `${AUTOMATION_BASE_PATH}${getAutomationEndpoint("list")}?${buildPaginationQuery(limit, offset)}`,
285
+ headers: await buildAutomationRequestHeaders(),
286
+ });
287
+ }
288
+
289
+ const { data } = await localAutomationAxios.get<AutomationsResponse>(
290
+ `${AUTOMATION_BASE_PATH}${getAutomationEndpoint("list")}`,
291
+ { params: { limit, offset } },
292
+ );
293
+ return data;
294
+ }
295
+
296
+ static async getAutomations(
297
+ limit = 50,
298
+ offset = 0,
299
+ ): Promise<AutomationsResponse> {
300
+ return AutomationService.listAutomations({ limit, offset });
301
+ }
302
+
303
+ static async getAutomation(id: string): Promise<Automation> {
304
+ const active = getActiveBackend().backend;
305
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("detail", id)}`;
306
+
307
+ if (active.kind === "cloud") {
308
+ return callCloudProxy<Automation>({
309
+ backend: active,
310
+ method: "GET",
311
+ path,
312
+ headers: await buildAutomationRequestHeaders(),
313
+ });
314
+ }
315
+
316
+ const { data } = await localAutomationAxios.get<Automation>(path);
317
+ return data;
318
+ }
319
+
320
+ static async createAutomation(spec: AutomationSpec): Promise<Automation> {
321
+ const active = getActiveBackend();
322
+ const { path, body } = buildCreateAutomationRequest(spec);
323
+
324
+ let created: Automation;
325
+ if (active.backend.kind === "cloud") {
326
+ created = await callCloudProxy<Automation>({
327
+ backend: active.backend,
328
+ method: "POST",
329
+ path,
330
+ body,
331
+ headers: await buildPinnedCloudHeaders(active),
332
+ });
333
+ } else {
334
+ const { data } = await localAutomationAxios.post<Automation>(
335
+ path,
336
+ body,
337
+ await buildPinnedLocalConfig(active.backend),
338
+ );
339
+ created = data;
340
+ }
341
+
342
+ const updatePath = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("detail", created.id)}`;
343
+ const updateBody: Partial<Automation> = {
344
+ trigger: buildImportedTrigger(spec),
345
+ enabled: false,
346
+ };
347
+
348
+ try {
349
+ if (active.backend.kind === "cloud") {
350
+ return await callCloudProxy<Automation>({
351
+ backend: active.backend,
352
+ method: "PATCH",
353
+ path: updatePath,
354
+ body: updateBody,
355
+ headers: await buildPinnedCloudHeaders(active),
356
+ });
357
+ }
358
+
359
+ const { data } = await localAutomationAxios.patch<Automation>(
360
+ updatePath,
361
+ updateBody,
362
+ await buildPinnedLocalConfig(active.backend),
363
+ );
364
+ return data;
365
+ } catch (updateError) {
366
+ try {
367
+ if (active.backend.kind === "cloud") {
368
+ await callCloudProxy<unknown>({
369
+ backend: active.backend,
370
+ method: "DELETE",
371
+ path: updatePath,
372
+ headers: await buildPinnedCloudHeaders(active),
373
+ });
374
+ } else {
375
+ await localAutomationAxios.delete(
376
+ updatePath,
377
+ await buildPinnedLocalConfig(active.backend),
378
+ );
379
+ }
380
+ } catch (cleanupError) {
381
+ throw new AggregateError(
382
+ [updateError, cleanupError],
383
+ "Failed to disable the imported automation and clean it up.",
384
+ );
385
+ }
386
+ throw updateError;
387
+ }
388
+ }
389
+
390
+ static async updateAutomation(
391
+ id: string,
392
+ body: Partial<Automation>,
393
+ ): Promise<Automation> {
394
+ const active = getActiveBackend().backend;
395
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("detail", id)}`;
396
+
397
+ if (active.kind === "cloud") {
398
+ return callCloudProxy<Automation>({
399
+ backend: active,
400
+ method: "PATCH",
401
+ path,
402
+ body: body as Record<string, unknown>,
403
+ headers: await buildAutomationRequestHeaders(),
404
+ });
405
+ }
406
+
407
+ const { data } = await localAutomationAxios.patch<Automation>(path, body);
408
+ return data;
409
+ }
410
+
411
+ static async deleteAutomation(id: string): Promise<void> {
412
+ const active = getActiveBackend().backend;
413
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("detail", id)}`;
414
+
415
+ if (active.kind === "cloud") {
416
+ await callCloudProxy<unknown>({
417
+ backend: active,
418
+ method: "DELETE",
419
+ path,
420
+ headers: await buildAutomationRequestHeaders(),
421
+ });
422
+ return;
423
+ }
424
+
425
+ await localAutomationAxios.delete(path);
426
+ }
427
+
428
+ static async dispatchAutomation(id: string): Promise<AutomationRun> {
429
+ const active = getActiveBackend().backend;
430
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("dispatch", id)}`;
431
+
432
+ if (active.kind === "cloud") {
433
+ return callCloudProxy<AutomationRun>({
434
+ backend: active,
435
+ method: "POST",
436
+ path,
437
+ headers: await buildAutomationRequestHeaders(),
438
+ });
439
+ }
440
+
441
+ const { data } = await localAutomationAxios.post<AutomationRun>(path);
442
+ return data;
443
+ }
444
+
445
+ /**
446
+ * Cancel a pending or running automation run.
447
+ * Backend: POST /api/automation/v1/runs/{run_id}/cancel
448
+ */
449
+ static async cancelAutomationRun(runId: string): Promise<AutomationRun> {
450
+ const active = getActiveBackend().backend;
451
+ const path = `${AUTOMATION_BASE_PATH}/v1/runs/${encodeURIComponent(runId)}/cancel`;
452
+
453
+ if (active.kind === "cloud") {
454
+ return callCloudProxy<AutomationRun>({
455
+ backend: active,
456
+ method: "POST",
457
+ path,
458
+ headers: await buildAutomationRequestHeaders(),
459
+ });
460
+ }
461
+
462
+ const { data } = await localAutomationAxios.post<AutomationRun>(path);
463
+ return data;
464
+ }
465
+
466
+ static async listAutomationRuns(
467
+ id: string,
468
+ params: { limit?: number; offset?: number } = {},
469
+ ): Promise<AutomationRunsResponse> {
470
+ const { limit = 50, offset = 0 } = params;
471
+ const active = getActiveBackend().backend;
472
+ const basePath = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("runs", id)}`;
473
+
474
+ if (active.kind === "cloud") {
475
+ return callCloudProxy<AutomationRunsResponse>({
476
+ backend: active,
477
+ method: "GET",
478
+ path: `${basePath}?${buildPaginationQuery(limit, offset)}`,
479
+ headers: await buildAutomationRequestHeaders(),
480
+ });
481
+ }
482
+
483
+ const { data } = await localAutomationAxios.get<AutomationRunsResponse>(
484
+ basePath,
485
+ { params: { limit, offset } },
486
+ );
487
+ return data;
488
+ }
489
+
490
+ static async getAutomationRuns(
491
+ id: string,
492
+ limit = 50,
493
+ offset = 0,
494
+ ): Promise<AutomationRunsResponse> {
495
+ return AutomationService.listAutomationRuns(id, { limit, offset });
496
+ }
497
+
498
+ static async toggleAutomation(
499
+ id: string,
500
+ enabled: boolean,
501
+ ): Promise<Automation> {
502
+ return AutomationService.updateAutomation(id, { enabled });
503
+ }
504
+
505
+ static async downloadTarball(id: string, name: string): Promise<void> {
506
+ const active = getActiveBackend().backend;
507
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationIdEndpoint("tarball", id)}`;
508
+
509
+ let blob: Blob;
510
+ if (active.kind === "cloud") {
511
+ blob = await callCloudProxy<Blob>({
512
+ backend: active,
513
+ method: "GET",
514
+ path,
515
+ responseType: "blob",
516
+ headers: await buildAutomationRequestHeaders(),
517
+ });
518
+ } else {
519
+ const { data } = await localAutomationAxios.get<Blob>(path, {
520
+ responseType: "blob",
521
+ });
522
+ blob = data;
523
+ }
524
+
525
+ const url = URL.createObjectURL(blob);
526
+ const a = document.createElement("a");
527
+ a.href = url;
528
+ a.download = `${name}.tar`;
529
+ a.click();
530
+ URL.revokeObjectURL(url);
531
+ }
532
+
533
+ /**
534
+ * Ask what this deployment supports, before a setup form renders.
535
+ *
536
+ * The setup endpoints answer a contract authored in `OpenHands/extensions`,
537
+ * so their bodies are camelCase where the rest of this service is snake_case.
538
+ */
539
+ static async getCapabilities(): Promise<DeploymentCapabilities> {
540
+ const active = getActiveBackend().backend;
541
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationEndpoint("capabilities")}`;
542
+
543
+ if (active.kind === "cloud") {
544
+ return callCloudProxy<DeploymentCapabilities>({
545
+ backend: active,
546
+ method: "GET",
547
+ path,
548
+ headers: await buildAutomationRequestHeaders(),
549
+ });
550
+ }
551
+
552
+ const { data } =
553
+ await localAutomationAxios.get<DeploymentCapabilities>(path);
554
+ return data;
555
+ }
556
+
557
+ /**
558
+ * Validate a draft without creating it. An invalid draft is a 200 carrying
559
+ * field-addressed errors; only a malformed envelope is a 4xx.
560
+ */
561
+ static async validateDraft(
562
+ body: SetupRequestBody,
563
+ ): Promise<ValidateDraftResponse> {
564
+ const active = getActiveBackend().backend;
565
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationEndpoint("validate")}`;
566
+
567
+ if (active.kind === "cloud") {
568
+ return callCloudProxy<ValidateDraftResponse>({
569
+ backend: active,
570
+ method: "POST",
571
+ path,
572
+ body,
573
+ headers: await buildAutomationRequestHeaders(),
574
+ });
575
+ }
576
+
577
+ const { data } = await localAutomationAxios.post<ValidateDraftResponse>(
578
+ path,
579
+ body,
580
+ );
581
+ return data;
582
+ }
583
+
584
+ /**
585
+ * Create from a draft a setup form derived. Unlike {@link
586
+ * AutomationService.createAutomation}, which exists for import and has to
587
+ * park the record on a placeholder trigger, this sends the finished record in
588
+ * one request.
589
+ */
590
+ static async createAutomationDraft(
591
+ body: SetupRequestBody,
592
+ /** The entry and selected action decide the create endpoint. */
593
+ entry?: SetupEntry,
594
+ selectedAction?: string | null,
595
+ ): Promise<Record<string, unknown>> {
596
+ const active = getActiveBackend().backend;
597
+ const path = `${AUTOMATION_BASE_PATH}${automationCreateEndpoint(
598
+ entry,
599
+ selectedAction,
600
+ )}`;
601
+
602
+ if (active.kind === "cloud") {
603
+ return callCloudProxy<Record<string, unknown>>({
604
+ backend: active,
605
+ method: "POST",
606
+ path,
607
+ body,
608
+ headers: await buildAutomationRequestHeaders(),
609
+ });
610
+ }
611
+
612
+ const { data } = await localAutomationAxios.post<Record<string, unknown>>(
613
+ path,
614
+ body,
615
+ );
616
+ return data;
617
+ }
618
+
619
+ /**
620
+ * Upload a packed bundle, and return the `oh-internal://` path the create
621
+ * call references.
622
+ *
623
+ * The body is the archive itself rather than a multipart form - the service
624
+ * streams it and takes its metadata from the query string, so it never has
625
+ * to buffer the whole file to start writing.
626
+ */
627
+ static async uploadAutomationTarball(
628
+ name: string,
629
+ archive: Uint8Array,
630
+ ): Promise<string> {
631
+ const active = getActiveBackend().backend;
632
+ const path =
633
+ `${AUTOMATION_BASE_PATH}${automationUploadEndpoint()}` +
634
+ `?name=${encodeURIComponent(name)}`;
635
+ const headers = { "Content-Type": "application/gzip" };
636
+
637
+ let upload: Record<string, unknown>;
638
+ if (active.kind === "cloud") {
639
+ // Post the archive straight to the cloud host rather than through the
640
+ // cloud client: that client JSON-serializes any non-FormData body, which
641
+ // would turn the gzip `Uint8Array` into `{"0":31,...}` even though the
642
+ // header says `application/gzip`. Axios preserves the raw bytes (its
643
+ // `transformRequest` sends the underlying buffer), so the service still
644
+ // receives the archive as the stream it expects, with metadata in the
645
+ // query string. This upload sets no host override, so a direct call
646
+ // matches the cloud client's own direct-to-host path -- we just add the
647
+ // two headers that path would (`Bearer` auth and `X-Org-Id`).
648
+ const { orgId } = getActiveBackend();
649
+ upload = (
650
+ await axios.post<Record<string, unknown>>(
651
+ `${active.host.replace(/\/+$/, "")}${path}`,
652
+ archive,
653
+ {
654
+ headers: {
655
+ ...(await buildAutomationRequestHeaders()),
656
+ ...headers,
657
+ ...(active.apiKey
658
+ ? { Authorization: `Bearer ${active.apiKey}` }
659
+ : {}),
660
+ ...(orgId ? { "X-Org-Id": orgId } : {}),
661
+ },
662
+ },
663
+ )
664
+ ).data;
665
+ } else {
666
+ upload = (
667
+ await localAutomationAxios.post<Record<string, unknown>>(
668
+ path,
669
+ archive,
670
+ { headers },
671
+ )
672
+ ).data;
673
+ }
674
+
675
+ const tarballPath = upload.tarball_path;
676
+ if (typeof tarballPath !== "string" || !tarballPath) {
677
+ throw new Error("The upload returned no tarball path.");
678
+ }
679
+ return tarballPath;
680
+ }
681
+
682
+ // Git sync paths are literal rather than routed through
683
+ // `getAutomationEndpoint`. That manifest describes the automation surface a
684
+ // host may remap, and `InterfaceEndpoints` requires every key it declares --
685
+ // adding these would break existing manifests. Git sync is a local-mode
686
+ // operator feature outside that surface.
687
+ static async getGitSyncStatus(): Promise<GitSyncStatus> {
688
+ const active = getActiveBackend().backend;
689
+ const path = `${AUTOMATION_BASE_PATH}/v1/git-sync/status`;
690
+
691
+ if (active.kind === "cloud") {
692
+ return callCloudProxy<GitSyncStatus>({
693
+ backend: active,
694
+ method: "GET",
695
+ path,
696
+ });
697
+ }
698
+
699
+ const { data } = await localAutomationAxios.get<GitSyncStatus>(path);
700
+ return data;
701
+ }
702
+
703
+ static async updateGitSyncConfig(
704
+ body: GitSyncConfigUpdateRequest,
705
+ ): Promise<GitSyncStatus> {
706
+ const active = getActiveBackend().backend;
707
+ const path = `${AUTOMATION_BASE_PATH}/v1/git-sync/config`;
708
+
709
+ if (active.kind === "cloud") {
710
+ return callCloudProxy<GitSyncStatus>({
711
+ backend: active,
712
+ method: "PUT",
713
+ path,
714
+ body: body as Record<string, unknown>,
715
+ });
716
+ }
717
+
718
+ const { data } = await localAutomationAxios.put<GitSyncStatus>(path, body);
719
+ return data;
720
+ }
721
+
722
+ /**
723
+ * Ask whether a configuration can reach its repo, without saving it. Takes
724
+ * the same body as `updateGitSyncConfig` and answers for the settings that
725
+ * body would leave in place.
726
+ */
727
+ static async checkGitSyncConfig(
728
+ body: GitSyncConfigUpdateRequest,
729
+ ): Promise<GitSyncCheckResponse> {
730
+ const active = getActiveBackend().backend;
731
+ const path = `${AUTOMATION_BASE_PATH}/v1/git-sync/check`;
732
+
733
+ if (active.kind === "cloud") {
734
+ return callCloudProxy<GitSyncCheckResponse>({
735
+ backend: active,
736
+ method: "POST",
737
+ path,
738
+ body: body as Record<string, unknown>,
739
+ });
740
+ }
741
+
742
+ const { data } = await localAutomationAxios.post<GitSyncCheckResponse>(
743
+ path,
744
+ body,
745
+ );
746
+ return data;
747
+ }
748
+
749
+ static async triggerGitSync(): Promise<GitSyncTriggerResponse> {
750
+ const active = getActiveBackend().backend;
751
+ const path = `${AUTOMATION_BASE_PATH}/v1/git-sync/sync`;
752
+
753
+ if (active.kind === "cloud") {
754
+ return callCloudProxy<GitSyncTriggerResponse>({
755
+ backend: active,
756
+ method: "POST",
757
+ path,
758
+ });
759
+ }
760
+
761
+ const { data } =
762
+ await localAutomationAxios.post<GitSyncTriggerResponse>(path);
763
+ return data;
764
+ }
765
+
766
+ static async checkHealth(): Promise<AutomationHealthResponse> {
767
+ const active = getActiveBackend().backend;
768
+ const path = `${AUTOMATION_BASE_PATH}${getAutomationEndpoint("health")}`;
769
+
770
+ try {
771
+ if (active.kind === "cloud") {
772
+ const response = await callCloudProxy<AutomationHealthResponse>({
773
+ backend: active,
774
+ method: "GET",
775
+ path,
776
+ // Fail fast, matching the local branch's 5s timeout below.
777
+ timeoutSeconds: 5,
778
+ headers: await buildAutomationRequestHeaders(),
779
+ });
780
+ return response;
781
+ }
782
+
783
+ const { data } = await localAutomationAxios.get<AutomationHealthResponse>(
784
+ path,
785
+ { timeout: 5000 },
786
+ );
787
+ return data;
788
+ } catch {
789
+ return { status: "error" };
790
+ }
791
+ }
792
+ }
793
+
794
+ export default AutomationService;
src/api/backend-registry/active-store.ts ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getBackendHealthEntry } from "./health-store";
2
+ import {
3
+ readStoredActiveBackend,
4
+ readStoredBackends,
5
+ writeStoredActiveBackend,
6
+ writeStoredBackends,
7
+ } from "./storage";
8
+ import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types";
9
+ import {
10
+ currentLocationSearch,
11
+ readBackendSelectionFromUrl,
12
+ } from "./url-selection";
13
+
14
+ type Listener = () => void;
15
+
16
+ interface Snapshot {
17
+ backends: Backend[];
18
+ selection: BackendSelection | null;
19
+ active: ResolvedActiveBackend;
20
+ }
21
+
22
+ export const NO_BACKEND_ID = "no-backend";
23
+
24
+ /**
25
+ * Sentinel returned when the registry has no usable backend. It must never be
26
+ * persisted, and callers must check `isNoBackend()` before interpreting fields
27
+ * like `kind`, `host`, or `apiKey`.
28
+ */
29
+ export const NO_BACKEND: Backend = {
30
+ id: NO_BACKEND_ID,
31
+ name: "No Backend Available",
32
+ host: "",
33
+ apiKey: "",
34
+ kind: "local",
35
+ };
36
+
37
+ export function isNoBackend(backend: Backend): boolean {
38
+ return backend.id === NO_BACKEND_ID;
39
+ }
40
+
41
+ /**
42
+ * Choose an active backend when there is no valid explicit selection (fresh
43
+ * start, or the selected backend was removed). Most of the GUI speaks the
44
+ * local agent-server protocol, so a cloud or dead-local backend at index 0
45
+ * would leave `getEffectiveLocalBackend()` null and make every local-protocol
46
+ * call throw "No backend is configured" even when a healthy local backend is
47
+ * registered further down the list. Consult the synchronous, persisted health
48
+ * store and prefer a healthy local backend; fall back to any local backend so
49
+ * the effective-local resolver still resolves, then to the prior deterministic
50
+ * `backends[0]` behavior for the registry-has-no-local case.
51
+ */
52
+ function pickFallbackBackend(backends: Backend[]): Backend {
53
+ const healthyLocalBackend = backends.find(
54
+ (backend) =>
55
+ backend.kind === "local" &&
56
+ getBackendHealthEntry(backend.id)?.disabled !== true,
57
+ );
58
+ if (healthyLocalBackend) return healthyLocalBackend;
59
+
60
+ const localBackend = backends.find((backend) => backend.kind === "local");
61
+ return localBackend ?? backends[0] ?? NO_BACKEND;
62
+ }
63
+
64
+ function computeSnapshot(
65
+ backends: Backend[],
66
+ selection: BackendSelection | null,
67
+ ): Snapshot {
68
+ let activeBackend: Backend | null = null;
69
+ let activeOrgId: string | null = null;
70
+
71
+ if (selection) {
72
+ const found = backends.find((b) => b.id === selection.backendId);
73
+ if (found) {
74
+ activeBackend = found;
75
+ activeOrgId = selection.orgId ?? null;
76
+ }
77
+ // If the selection points at a removed backend, fall through to
78
+ // the unselected case below; we also drop the orgId since it only
79
+ // makes sense in the context of a specific cloud backend.
80
+ }
81
+
82
+ // @spec BM-003 — Fallback on active backend removal
83
+ if (!activeBackend) {
84
+ activeBackend = pickFallbackBackend(backends);
85
+ activeOrgId = null;
86
+ }
87
+
88
+ return {
89
+ backends,
90
+ selection,
91
+ active: { backend: activeBackend, orgId: activeOrgId },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Resolve the selection this tab boots with. A backend pinned in the URL wins
97
+ * over stored state so a link opened in a new tab (cmd/ctrl-click, middle
98
+ * click, "Open in new tab") lands on the backend that owns the linked
99
+ * conversation, even when the new tab starts with an empty `sessionStorage`
100
+ * and would otherwise adopt the `localStorage` fallback. The honoured
101
+ * selection is persisted so later in-tab navigation — which drops the query
102
+ * parameters — keeps the same backend.
103
+ */
104
+ function readInitialSelection(backends: Backend[]): BackendSelection | null {
105
+ const fromUrl = readBackendSelectionFromUrl(
106
+ backends,
107
+ currentLocationSearch(),
108
+ );
109
+ if (fromUrl) {
110
+ writeStoredActiveBackend(fromUrl);
111
+ return fromUrl;
112
+ }
113
+ return readStoredActiveBackend();
114
+ }
115
+
116
+ const initialBackends = readStoredBackends();
117
+ let snapshot: Snapshot = computeSnapshot(
118
+ initialBackends,
119
+ readInitialSelection(initialBackends),
120
+ );
121
+
122
+ const listeners = new Set<Listener>();
123
+
124
+ function notify(): void {
125
+ listeners.forEach((listener) => listener());
126
+ }
127
+
128
+ export function getActiveBackend(): ResolvedActiveBackend {
129
+ return snapshot.active;
130
+ }
131
+
132
+ /**
133
+ * Pick the backend to use for *local agent-server protocol* calls.
134
+ *
135
+ * Most of the GUI's services (settings reads/writes, conversation CRUD,
136
+ * skills/MCP/secrets, etc.) speak the local agent-server's protocol —
137
+ * they would fail against a cloud host. Only the active backend is eligible:
138
+ * a cloud selection must not borrow another registered local backend.
139
+ */
140
+ export function getEffectiveLocalBackend(): Backend | null {
141
+ const active = snapshot.active.backend;
142
+ if (active.kind === "local" && !isNoBackend(active)) return active;
143
+ return null;
144
+ }
145
+
146
+ export function getRegisteredBackends(): Backend[] {
147
+ return snapshot.backends;
148
+ }
149
+
150
+ export function getActiveSelection(): BackendSelection | null {
151
+ return snapshot.selection;
152
+ }
153
+
154
+ export function getSnapshot(): Snapshot {
155
+ return snapshot;
156
+ }
157
+
158
+ export function setActiveSelection(selection: BackendSelection | null): void {
159
+ writeStoredActiveBackend(selection);
160
+ snapshot = computeSnapshot(snapshot.backends, selection);
161
+ notify();
162
+ }
163
+
164
+ export function setRegisteredBackends(backends: Backend[]): void {
165
+ writeStoredBackends(backends);
166
+
167
+ let nextSelection = snapshot.selection;
168
+ if (
169
+ nextSelection &&
170
+ !backends.some((b) => b.id === nextSelection!.backendId)
171
+ ) {
172
+ nextSelection = null;
173
+ writeStoredActiveBackend(null);
174
+ }
175
+
176
+ snapshot = computeSnapshot(backends, nextSelection);
177
+ notify();
178
+ }
179
+
180
+ export function subscribeActiveBackend(listener: Listener): () => void {
181
+ listeners.add(listener);
182
+ return () => {
183
+ listeners.delete(listener);
184
+ };
185
+ }
186
+
187
+ /** Test-only: re-read storage and clear listeners. */
188
+
189
+ export function __resetActiveStoreForTests(): void {
190
+ const backends = readStoredBackends();
191
+ snapshot = computeSnapshot(backends, readInitialSelection(backends));
192
+ listeners.clear();
193
+ }
src/api/backend-registry/auth.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Backend } from "./types";
2
+
3
+ /**
4
+ * Build the auth headers to send to a backend.
5
+ *
6
+ * Local agent-server uses `X-Session-API-Key`. Cloud expects a bearer
7
+ * token in the `Authorization` header.
8
+ */
9
+ export function buildAuthHeaders(backend: Backend): Record<string, string> {
10
+ if (backend.kind === "cloud" && backend.authMode === "cookie") return {};
11
+ if (!backend.apiKey) return {};
12
+
13
+ if (backend.kind === "cloud") {
14
+ return { Authorization: `Bearer ${backend.apiKey}` };
15
+ }
16
+
17
+ return { "X-Session-API-Key": backend.apiKey };
18
+ }
src/api/backend-registry/default-backend.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getAgentServerBaseUrl,
3
+ getAgentServerSessionApiKey,
4
+ getCookieAuthCloudHost,
5
+ getLockedCloudHost,
6
+ } from "../agent-server-config";
7
+ import type { Backend } from "./types";
8
+
9
+ /**
10
+ * Stable id for the seeded default local backend that is auto-registered in
11
+ * the backend registry when the launcher provides both a backend host and
12
+ * API key. After seeding, this backend is a normal registered entry — the
13
+ * user can rename it, edit its host/api key, or remove it like any other
14
+ * backend.
15
+ */
16
+ export const SEEDED_DEFAULT_BACKEND_ID = "default-local";
17
+
18
+ export const DEFAULT_LOCAL_BACKEND_NAME = "Local";
19
+ export const LOCKED_CLOUD_BACKEND_ID = "locked-cloud";
20
+ export const LOCKED_CLOUD_BACKEND_NAME = "OpenHands Cloud";
21
+
22
+ export function makeLockedCloudBackend(): Backend | null {
23
+ if (!getLockedCloudHost()) return null;
24
+
25
+ const host = getCookieAuthCloudHost();
26
+ if (!host) return null;
27
+
28
+ return {
29
+ id: LOCKED_CLOUD_BACKEND_ID,
30
+ name: LOCKED_CLOUD_BACKEND_NAME,
31
+ host,
32
+ apiKey: "",
33
+ kind: "cloud",
34
+ authMode: "cookie",
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Construct the default local backend from environment/runtime config.
40
+ * Returns null unless both a backend location and API key are available.
41
+ *
42
+ * Used as the seed entry written to `openhands-backends` on first load;
43
+ * if it returns null, onboarding is responsible for collecting backend
44
+ * connection details from the user.
45
+ *
46
+ * Returns null when the deployment is locked to a single OpenHands Cloud
47
+ * host (`VITE_LOCK_TO_CLOUD` / `--lock-to-cloud`). In locked mode the user
48
+ * can only authenticate against the configured Cloud URL, so seeding a
49
+ * Local backend from a baked/injected session key would short-circuit the
50
+ * first-run onboarding gate and strand the user on the Manage Backends
51
+ * recovery modal with a disconnected Local entry.
52
+ */
53
+ export function makeDefaultLocalBackend(): Backend | null {
54
+ // Locked-to-Cloud deployments must never auto-seed a Local backend —
55
+ // see the docblock above.
56
+ if (getLockedCloudHost()) return null;
57
+
58
+ const host = getAgentServerBaseUrl();
59
+ const apiKey = getAgentServerSessionApiKey();
60
+
61
+ if (!host || !apiKey) return null;
62
+
63
+ return {
64
+ id: SEEDED_DEFAULT_BACKEND_ID,
65
+ name: DEFAULT_LOCAL_BACKEND_NAME,
66
+ host,
67
+ apiKey,
68
+ kind: "local",
69
+ };
70
+ }
src/api/backend-registry/health-storage.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error";
2
+
3
+ export const BACKEND_HEALTH_STORAGE_KEY = "openhands-backend-health";
4
+
5
+ /**
6
+ * Once a backend has failed this many probes in a row, polling stops
7
+ * until the user edits the backend's host or apiKey (which resets the
8
+ * counter via the active-backend store).
9
+ */
10
+ export const MAX_CONSECUTIVE_FAILURES = 5;
11
+
12
+ /** Max characters of `error.message` we persist — keeps localStorage tidy. */
13
+ const MAX_ERROR_MESSAGE_LENGTH = 500;
14
+
15
+ export interface BackendHealthEntry {
16
+ consecutiveFailures: number;
17
+ lastError: string | null;
18
+ lastFailureAt: number | null;
19
+ disabled: boolean;
20
+ }
21
+
22
+ export type BackendHealthMap = Record<string, BackendHealthEntry>;
23
+
24
+ function isValidEntry(value: unknown): value is BackendHealthEntry {
25
+ if (typeof value !== "object" || value === null) return false;
26
+ const v = value as Partial<BackendHealthEntry>;
27
+ // localStorage is user-writable. Reject anything outside the range
28
+ // our own writer produces — a tampered `-1` would never reach the
29
+ // cap and would defeat the whole disable mechanism, and a giant
30
+ // value would clutter the UI for no reason.
31
+ return (
32
+ Number.isInteger(v.consecutiveFailures) &&
33
+ (v.consecutiveFailures as number) >= 0 &&
34
+ (v.consecutiveFailures as number) <= MAX_CONSECUTIVE_FAILURES &&
35
+ (v.lastError === null || typeof v.lastError === "string") &&
36
+ (v.lastFailureAt === null || typeof v.lastFailureAt === "number") &&
37
+ typeof v.disabled === "boolean"
38
+ );
39
+ }
40
+
41
+ export function readStoredHealth(): BackendHealthMap {
42
+ if (typeof window === "undefined") return {};
43
+ try {
44
+ const raw = window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY);
45
+ if (!raw) return {};
46
+ const parsed = JSON.parse(raw);
47
+ if (typeof parsed !== "object" || parsed === null) return {};
48
+
49
+ const out: BackendHealthMap = {};
50
+ for (const [id, entry] of Object.entries(parsed)) {
51
+ if (typeof id === "string" && id.length > 0 && isValidEntry(entry)) {
52
+ out[id] = entry;
53
+ }
54
+ }
55
+ return out;
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ export function writeStoredHealth(map: BackendHealthMap): void {
62
+ if (typeof window === "undefined") return;
63
+ try {
64
+ if (Object.keys(map).length === 0) {
65
+ window.localStorage.removeItem(BACKEND_HEALTH_STORAGE_KEY);
66
+ return;
67
+ }
68
+ window.localStorage.setItem(
69
+ BACKEND_HEALTH_STORAGE_KEY,
70
+ JSON.stringify(map),
71
+ );
72
+ } catch {
73
+ /* ignore quota / serialization errors */
74
+ }
75
+ }
76
+
77
+ export function truncateErrorMessage(error: unknown): string {
78
+ const message = getUserFacingConnectionErrorMessage(error) ?? "Unknown error";
79
+ return message.length > MAX_ERROR_MESSAGE_LENGTH
80
+ ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}…`
81
+ : message;
82
+ }
src/api/backend-registry/health-store.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ MAX_CONSECUTIVE_FAILURES,
3
+ readStoredHealth,
4
+ truncateErrorMessage,
5
+ writeStoredHealth,
6
+ type BackendHealthEntry,
7
+ type BackendHealthMap,
8
+ } from "./health-storage";
9
+
10
+ type Listener = () => void;
11
+
12
+ let healthMap: BackendHealthMap = readStoredHealth();
13
+ const listeners = new Set<Listener>();
14
+
15
+ function notify(): void {
16
+ listeners.forEach((listener) => listener());
17
+ }
18
+
19
+ function commit(next: BackendHealthMap): void {
20
+ healthMap = next;
21
+ writeStoredHealth(next);
22
+ notify();
23
+ }
24
+
25
+ export function getHealthSnapshot(): BackendHealthMap {
26
+ return healthMap;
27
+ }
28
+
29
+ export function getBackendHealthEntry(id: string): BackendHealthEntry | null {
30
+ return healthMap[id] ?? null;
31
+ }
32
+
33
+ export function subscribeBackendHealth(listener: Listener): () => void {
34
+ listeners.add(listener);
35
+ return () => {
36
+ listeners.delete(listener);
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Increment the per-backend failure counter. Once the count reaches
42
+ * `MAX_CONSECUTIVE_FAILURES`, the entry is marked `disabled: true`
43
+ * which the polling hook reads to stop firing probes — including on a
44
+ * fresh page load, since the state lives in localStorage.
45
+ */
46
+ export function recordBackendFailure(id: string, error: unknown): void {
47
+ const prev = healthMap[id];
48
+ const consecutiveFailures = Math.min(
49
+ (prev?.consecutiveFailures ?? 0) + 1,
50
+ MAX_CONSECUTIVE_FAILURES,
51
+ );
52
+ const disabled =
53
+ prev?.disabled === true || consecutiveFailures >= MAX_CONSECUTIVE_FAILURES;
54
+
55
+ const nextEntry: BackendHealthEntry = {
56
+ consecutiveFailures,
57
+ lastError: truncateErrorMessage(error),
58
+ lastFailureAt: Date.now(),
59
+ disabled,
60
+ };
61
+
62
+ commit({ ...healthMap, [id]: nextEntry });
63
+ }
64
+
65
+ /**
66
+ * Clear the entry so the next 10s tick can mark it healthy again.
67
+ * Called on a successful probe — also covers the case where a backend
68
+ * had a few failures but recovered before hitting the cap.
69
+ */
70
+ export function recordBackendSuccess(id: string): void {
71
+ if (!(id in healthMap)) return;
72
+ const { [id]: _removed, ...rest } = healthMap;
73
+ commit(rest);
74
+ }
75
+
76
+ /**
77
+ * Re-arm polling for a backend after the user updates its config.
78
+ * Identical to `recordBackendSuccess` today, but kept distinct so the
79
+ * call sites read clearly.
80
+ */
81
+ export function resetBackendHealth(id: string): void {
82
+ if (!(id in healthMap)) return;
83
+ const { [id]: _removed, ...rest } = healthMap;
84
+ commit(rest);
85
+ }
86
+
87
+ /** Drop the entry entirely — used when the backend is deleted. */
88
+ export function dropBackendHealth(id: string): void {
89
+ if (!(id in healthMap)) return;
90
+ const { [id]: _removed, ...rest } = healthMap;
91
+ commit(rest);
92
+ }
93
+
94
+ /** Test-only: re-read storage and clear listeners. */
95
+
96
+ export function __resetHealthStoreForTests(): void {
97
+ healthMap = readStoredHealth();
98
+ listeners.clear();
99
+ }
src/api/backend-registry/last-conversation-store.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Per-(backend, org) memory of the most recently selected conversation.
2
+ //
3
+ // Used by `BackendSelector` so that flipping from backend A → B while a
4
+ // conversation was active in A jumps straight to B's most recent
5
+ // conversation (if any) instead of dropping the user on the home page.
6
+ // Plain `localStorage` is sufficient: reads happen synchronously during
7
+ // the dropdown's onChange and the data is purely a UX shortcut — losing
8
+ // it just falls back to the conversations list.
9
+
10
+ export const LAST_CONVERSATION_STORAGE_KEY =
11
+ "openhands-last-conversation-by-backend";
12
+
13
+ type StoredMap = Record<string, string>;
14
+
15
+ function selectionKey(backendId: string, orgId: string | null): string {
16
+ return orgId ? `${backendId}::${orgId}` : backendId;
17
+ }
18
+
19
+ function readMap(): StoredMap {
20
+ if (typeof window === "undefined") return {};
21
+ try {
22
+ const raw = window.localStorage.getItem(LAST_CONVERSATION_STORAGE_KEY);
23
+ if (!raw) return {};
24
+ const parsed = JSON.parse(raw) as unknown;
25
+ if (typeof parsed !== "object" || parsed === null) return {};
26
+ const out: StoredMap = {};
27
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
28
+ if (typeof v === "string" && v.length > 0) out[k] = v;
29
+ }
30
+ return out;
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function writeMap(map: StoredMap): void {
37
+ if (typeof window === "undefined") return;
38
+ try {
39
+ window.localStorage.setItem(
40
+ LAST_CONVERSATION_STORAGE_KEY,
41
+ JSON.stringify(map),
42
+ );
43
+ } catch {
44
+ /* ignore quota / serialization errors */
45
+ }
46
+ }
47
+
48
+ export function getLastConversationId(
49
+ backendId: string,
50
+ orgId: string | null,
51
+ ): string | null {
52
+ return readMap()[selectionKey(backendId, orgId)] ?? null;
53
+ }
54
+
55
+ export function setLastConversationId(
56
+ backendId: string,
57
+ orgId: string | null,
58
+ conversationId: string,
59
+ ): void {
60
+ if (!conversationId) return;
61
+ const map = readMap();
62
+ map[selectionKey(backendId, orgId)] = conversationId;
63
+ writeMap(map);
64
+ }
65
+
66
+ export function clearLastConversationId(
67
+ backendId: string,
68
+ orgId: string | null,
69
+ ): void {
70
+ const map = readMap();
71
+ const key = selectionKey(backendId, orgId);
72
+ if (key in map) {
73
+ delete map[key];
74
+ writeMap(map);
75
+ }
76
+ }
src/api/backend-registry/storage.ts ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ SEEDED_DEFAULT_BACKEND_ID,
3
+ makeDefaultLocalBackend,
4
+ makeLockedCloudBackend,
5
+ } from "./default-backend";
6
+ import type {
7
+ Backend,
8
+ BackendAuthMode,
9
+ BackendKind,
10
+ BackendSelection,
11
+ } from "./types";
12
+
13
+ export const BACKENDS_STORAGE_KEY = "openhands-backends";
14
+ export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend";
15
+
16
+ function isValidKind(value: unknown): value is BackendKind {
17
+ return value === "local" || value === "cloud";
18
+ }
19
+
20
+ function isValidAuthMode(value: unknown): value is BackendAuthMode {
21
+ return value === undefined || value === "api-key" || value === "cookie";
22
+ }
23
+
24
+ function isValidBackend(value: unknown): value is Backend {
25
+ if (typeof value !== "object" || value === null) return false;
26
+ const v = value as Partial<Backend>;
27
+ return (
28
+ typeof v.id === "string" &&
29
+ v.id.length > 0 &&
30
+ typeof v.name === "string" &&
31
+ typeof v.host === "string" &&
32
+ typeof v.apiKey === "string" &&
33
+ isValidKind(v.kind) &&
34
+ isValidAuthMode(v.authMode) &&
35
+ (v.connectionRevision === undefined ||
36
+ (typeof v.connectionRevision === "number" &&
37
+ Number.isSafeInteger(v.connectionRevision) &&
38
+ v.connectionRevision >= 0))
39
+ );
40
+ }
41
+
42
+ function isLoopbackUrl(value: string): boolean {
43
+ try {
44
+ const { hostname } = new URL(value);
45
+ return (
46
+ hostname === "localhost" ||
47
+ hostname === "127.0.0.1" ||
48
+ hostname === "::1" ||
49
+ hostname === "[::1]"
50
+ );
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function shouldSyncLauncherDefaultLocalBackend(
57
+ backend: Backend,
58
+ defaultBackend: Backend,
59
+ ): boolean {
60
+ if (backend.id !== SEEDED_DEFAULT_BACKEND_ID || backend.kind !== "local") {
61
+ return false;
62
+ }
63
+
64
+ return (
65
+ backend.host === defaultBackend.host ||
66
+ (isLoopbackUrl(backend.host) && isLoopbackUrl(defaultBackend.host))
67
+ );
68
+ }
69
+
70
+ function syncLauncherDefaultLocalBackend(backends: Backend[]): Backend[] {
71
+ const defaultBackend = makeDefaultLocalBackend();
72
+ if (!defaultBackend) return backends;
73
+
74
+ let didSync = false;
75
+ const syncedBackends = backends.map((backend) => {
76
+ if (!shouldSyncLauncherDefaultLocalBackend(backend, defaultBackend)) {
77
+ return backend;
78
+ }
79
+
80
+ if (backend.apiKey === defaultBackend.apiKey) return backend;
81
+
82
+ didSync = true;
83
+ return {
84
+ ...backend,
85
+ apiKey: defaultBackend.apiKey,
86
+ };
87
+ });
88
+
89
+ if (!didSync) return backends;
90
+
91
+ writeStoredBackends(syncedBackends);
92
+ return syncedBackends;
93
+ }
94
+
95
+ export function writeStoredBackends(backends: Backend[]): void {
96
+ if (typeof window === "undefined") return;
97
+ try {
98
+ window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends));
99
+ } catch {
100
+ /* ignore quota / serialization errors */
101
+ }
102
+ }
103
+
104
+ export function readStoredBackends(): Backend[] {
105
+ if (typeof window === "undefined") return [];
106
+
107
+ try {
108
+ const lockedCloudBackend = makeLockedCloudBackend();
109
+ if (lockedCloudBackend) {
110
+ writeStoredBackends([lockedCloudBackend]);
111
+ const activeSelection = readStoredActiveBackend();
112
+ if (activeSelection?.backendId !== lockedCloudBackend.id) {
113
+ writeStoredActiveBackend({ backendId: lockedCloudBackend.id });
114
+ }
115
+ return [lockedCloudBackend];
116
+ }
117
+
118
+ const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY);
119
+
120
+ // First install: seed only when the launcher supplied enough information
121
+ // for a usable local backend.
122
+ if (raw === null) {
123
+ const defaultBackend = makeDefaultLocalBackend();
124
+ if (!defaultBackend) return [];
125
+
126
+ writeStoredBackends([defaultBackend]);
127
+ return [defaultBackend];
128
+ }
129
+
130
+ const parsed = JSON.parse(raw);
131
+ if (!Array.isArray(parsed)) return [];
132
+ const valid = parsed.filter(isValidBackend);
133
+
134
+ // If the stored array is empty (or everything in it failed validation),
135
+ // only re-seed when the launcher supplied both a host and API key.
136
+ if (valid.length === 0) {
137
+ const defaultBackend = makeDefaultLocalBackend();
138
+ if (!defaultBackend) return [];
139
+
140
+ writeStoredBackends([defaultBackend]);
141
+ return [defaultBackend];
142
+ }
143
+
144
+ const synced = syncLauncherDefaultLocalBackend(valid);
145
+ return synced;
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
150
+
151
+ function parseBackendSelection(raw: string | null): BackendSelection | null {
152
+ if (!raw) return null;
153
+
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ if (
157
+ typeof parsed !== "object" ||
158
+ parsed === null ||
159
+ typeof (parsed as BackendSelection).backendId !== "string"
160
+ ) {
161
+ return null;
162
+ }
163
+ const orgIdRaw = (parsed as BackendSelection).orgId;
164
+ return {
165
+ backendId: (parsed as BackendSelection).backendId,
166
+ orgId:
167
+ typeof orgIdRaw === "string" && orgIdRaw.length > 0 ? orgIdRaw : null,
168
+ };
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function readStorageItem(
175
+ storage: Storage | undefined,
176
+ key: string,
177
+ ): string | null {
178
+ try {
179
+ return storage?.getItem(key) ?? null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function writeStorageItem(
186
+ storage: Storage | undefined,
187
+ key: string,
188
+ value: string,
189
+ ): void {
190
+ try {
191
+ storage?.setItem(key, value);
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ }
196
+
197
+ function removeStorageItem(storage: Storage | undefined, key: string): void {
198
+ try {
199
+ storage?.removeItem(key);
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ }
204
+
205
+ export function readStoredActiveBackend(): BackendSelection | null {
206
+ if (typeof window === "undefined") return null;
207
+
208
+ // Active backend is tab-scoped so reloading tab A does not adopt tab B's
209
+ // backend. localStorage remains a last-used fallback for fresh tabs and old
210
+ // persisted state.
211
+ const sessionSelection = parseBackendSelection(
212
+ readStorageItem(window.sessionStorage, ACTIVE_BACKEND_STORAGE_KEY),
213
+ );
214
+ if (sessionSelection) return sessionSelection;
215
+
216
+ return parseBackendSelection(
217
+ readStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY),
218
+ );
219
+ }
220
+
221
+ export function writeStoredActiveBackend(
222
+ selection: BackendSelection | null,
223
+ ): void {
224
+ if (typeof window === "undefined") return;
225
+
226
+ if (!selection) {
227
+ removeStorageItem(window.sessionStorage, ACTIVE_BACKEND_STORAGE_KEY);
228
+ removeStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY);
229
+ return;
230
+ }
231
+
232
+ const serialized = JSON.stringify({
233
+ backendId: selection.backendId,
234
+ orgId: selection.orgId ?? null,
235
+ });
236
+ // Mirror to localStorage only as the default for new tabs/backward
237
+ // compatibility; reads in existing tabs prefer sessionStorage.
238
+ writeStorageItem(
239
+ window.sessionStorage,
240
+ ACTIVE_BACKEND_STORAGE_KEY,
241
+ serialized,
242
+ );
243
+ writeStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY, serialized);
244
+ }
src/api/backend-registry/types.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type BackendKind = "local" | "cloud";
2
+ export type BackendAuthMode = "api-key" | "cookie";
3
+
4
+ export interface Backend {
5
+ id: string;
6
+ name: string;
7
+ host: string;
8
+ apiKey: string;
9
+ kind: BackendKind;
10
+ authMode?: BackendAuthMode;
11
+ /** Changes whenever connection credentials change, invalidating keyed data. */
12
+ connectionRevision?: number;
13
+ }
14
+
15
+ export interface BackendSelection {
16
+ backendId: string;
17
+ orgId?: string | null;
18
+ }
19
+
20
+ export interface ResolvedActiveBackend {
21
+ backend: Backend;
22
+ orgId: string | null;
23
+ }
src/api/backend-registry/url-selection.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types";
2
+
3
+ /**
4
+ * Query parameters that pin a link to the backend (and cloud org) that owns
5
+ * the linked resource.
6
+ *
7
+ * The active backend is tab-scoped: `readStoredActiveBackend()` prefers
8
+ * `sessionStorage` and only falls back to `localStorage`. A tab opened with
9
+ * cmd/ctrl-click or middle-click does not reliably inherit the opener's
10
+ * `sessionStorage`, so it can boot from the `localStorage` fallback — which
11
+ * holds whichever backend was selected *last in any tab*, not the one the
12
+ * sidebar we were just looking at belongs to. The new tab then resolves the
13
+ * conversation id against the wrong backend and shows "conversation not
14
+ * found". Carrying the identity in the URL makes the link self-describing so
15
+ * the new tab pins to the right backend regardless of what storage holds.
16
+ */
17
+ export const BACKEND_QUERY_PARAM = "backend";
18
+ export const ORG_QUERY_PARAM = "org";
19
+
20
+ /**
21
+ * Append the active backend identity to an in-app path so opening it in a new
22
+ * browsing context resolves against the same backend. Any fragment on the
23
+ * path is preserved verbatim and kept after the query string.
24
+ */
25
+ export function withBackendSelectionParams(
26
+ path: string,
27
+ active: ResolvedActiveBackend,
28
+ ): string {
29
+ const { backend, orgId } = active;
30
+ if (!backend.id) return path;
31
+
32
+ const hashIndex = path.indexOf("#");
33
+ const fragment = hashIndex === -1 ? "" : path.slice(hashIndex);
34
+ const withoutFragment = hashIndex === -1 ? path : path.slice(0, hashIndex);
35
+
36
+ // Split at the *first* `?` only. A later `?` is ordinary query data (a
37
+ // `next=` redirect carrying its own query, say), and `split("?")` would
38
+ // silently drop everything past it.
39
+ const queryIndex = withoutFragment.indexOf("?");
40
+ const pathname =
41
+ queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex);
42
+ const existingSearch =
43
+ queryIndex === -1 ? "" : withoutFragment.slice(queryIndex + 1);
44
+
45
+ const params = new URLSearchParams(existingSearch);
46
+ params.set(BACKEND_QUERY_PARAM, backend.id);
47
+ if (orgId) params.set(ORG_QUERY_PARAM, orgId);
48
+
49
+ return `${pathname}?${params.toString()}${fragment}`;
50
+ }
51
+
52
+ /**
53
+ * Read a backend selection off the current URL, keeping it only when it names
54
+ * a registered backend. An unknown id (a link from another machine, or a
55
+ * backend that has since been removed) is ignored so the caller falls back to
56
+ * the stored selection.
57
+ */
58
+ export function readBackendSelectionFromUrl(
59
+ backends: Backend[],
60
+ search: string,
61
+ ): BackendSelection | null {
62
+ if (!search) return null;
63
+
64
+ let params: URLSearchParams;
65
+ try {
66
+ params = new URLSearchParams(search);
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ const backendId = params.get(BACKEND_QUERY_PARAM);
72
+ if (!backendId) return null;
73
+ if (!backends.some((backend) => backend.id === backendId)) return null;
74
+
75
+ const orgId = params.get(ORG_QUERY_PARAM);
76
+ return { backendId, orgId: orgId || null };
77
+ }
78
+
79
+ /** The current tab's query string, or "" outside a browser. */
80
+ export function currentLocationSearch(): string {
81
+ if (typeof window === "undefined") return "";
82
+ return window.location.search;
83
+ }
src/api/bash-service/bash-service.api.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BashClient } from "@openhands/typescript-client/clients";
2
+ import type {
3
+ BashEvent,
4
+ BashEventPage,
5
+ BashOutput,
6
+ } from "@openhands/typescript-client";
7
+ import { buildHttpBaseUrl } from "#/utils/websocket-url";
8
+ import { getActiveBackend } from "../backend-registry/active-store";
9
+ import { callCloudProxy } from "../cloud/proxy";
10
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
11
+
12
+ interface SearchOptions {
13
+ kind__eq?: "BashCommand" | "BashOutput";
14
+ command_id__eq?: string;
15
+ sort_order?: "TIMESTAMP" | "TIMESTAMP_DESC";
16
+ page_id?: string;
17
+ limit?: number;
18
+ }
19
+
20
+ const MAX_OUTPUT_PAGES = 20; // safety cap; >2000 output events is unlikely.
21
+
22
+ function isBashOutput(event: BashEvent): event is BashOutput {
23
+ return event.kind === "BashOutput";
24
+ }
25
+
26
+ /**
27
+ * Cloud-aware bash event reads.
28
+ *
29
+ * Bash events live on the agent-server runtime that owns the
30
+ * conversation. In **local** mode we talk to the active backend's
31
+ * agent-server directly with the SDK's `BashClient` (a per-conversation
32
+ * URL is honoured when known, otherwise we fall back to the backend
33
+ * host — a single local agent-server hosts all conversations). In
34
+ * **cloud** mode we tunnel through `callCloudProxy` with the runtime URL
35
+ * as `hostOverride`: direct browser calls to `*.prod-runtime.all-hands.dev`
36
+ * are blocked by CORS, and runtime endpoints authenticate with the
37
+ * conversation's `X-Session-API-Key`.
38
+ *
39
+ * Note on the search filter name: the agent-server API uses
40
+ * `command_id__eq` (not `bash_command_id__eq`) — that's the parameter the
41
+ * `BashService.search_bash_events` Python implementation declares and
42
+ * what the typescript-client's `BashEventSearchOptions` exposes.
43
+ */
44
+ class BashService {
45
+ /**
46
+ * Fetch all `BashOutput` events for a bash command, paginated and
47
+ * sorted by timestamp. Returns events in command-emission order so
48
+ * callers can concatenate `stdout` / `stderr` values directly.
49
+ */
50
+ static async listOutputs(
51
+ conversationUrl: string | null,
52
+ sessionApiKey: string | null | undefined,
53
+ bashCommandId: string,
54
+ ): Promise<BashOutput[]> {
55
+ const outputs: BashOutput[] = [];
56
+ let pageId: string | undefined;
57
+ for (let i = 0; i < MAX_OUTPUT_PAGES; i += 1) {
58
+ const page = await BashService.searchEvents(
59
+ conversationUrl,
60
+ sessionApiKey,
61
+ {
62
+ kind__eq: "BashOutput",
63
+ command_id__eq: bashCommandId,
64
+ sort_order: "TIMESTAMP",
65
+ ...(pageId ? { page_id: pageId } : {}),
66
+ },
67
+ );
68
+ page.items.forEach((event) => {
69
+ if (isBashOutput(event)) outputs.push(event);
70
+ });
71
+ if (!page.next_page_id) break;
72
+ pageId = page.next_page_id;
73
+ }
74
+ return outputs;
75
+ }
76
+
77
+ private static async searchEvents(
78
+ conversationUrl: string | null,
79
+ sessionApiKey: string | null | undefined,
80
+ options: SearchOptions,
81
+ ): Promise<BashEventPage> {
82
+ const active = getActiveBackend().backend;
83
+
84
+ if (active.kind === "cloud") {
85
+ // Cloud requires the per-conversation runtime URL — there is no
86
+ // shared cloud host that owns bash events. Callers must wait for
87
+ // the conversation to be hydrated before invoking this method on
88
+ // a cloud backend.
89
+ if (!conversationUrl) {
90
+ throw new Error(
91
+ "BashService.listOutputs requires a conversation URL on cloud backends",
92
+ );
93
+ }
94
+ const params = new URLSearchParams();
95
+ Object.entries(options).forEach(([k, v]) => {
96
+ if (v !== undefined && v !== null) params.set(k, String(v));
97
+ });
98
+ return callCloudProxy<BashEventPage>({
99
+ backend: active,
100
+ method: "GET",
101
+ hostOverride: buildHttpBaseUrl(conversationUrl),
102
+ path: `/api/bash/bash_events/search?${params.toString()}`,
103
+ authMode: "session-api-key",
104
+ sessionApiKey,
105
+ });
106
+ }
107
+
108
+ // Local mode: the active backend's agent-server hosts the bash
109
+ // events. The optional `conversationUrl` is used when present (lets
110
+ // us target a per-conversation sub-host), otherwise we fall through
111
+ // to `backend.host` via `getAgentServerClientOptions`.
112
+ return new BashClient(
113
+ getAgentServerClientOptions({
114
+ ...(conversationUrl ? { conversationUrl } : {}),
115
+ sessionApiKey,
116
+ }),
117
+ ).searchEvents(options);
118
+ }
119
+ }
120
+
121
+ export default BashService;
src/api/cloud/agent-profiles-service.api.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ AgentProfileSaveInput,
3
+ AgentProfileListResponse,
4
+ AgentProfileDetailResponse,
5
+ AgentProfileMutationResponse,
6
+ ActivateAgentProfileResponse,
7
+ } from "@openhands/typescript-client";
8
+ import { getActiveBackend } from "../backend-registry/active-store";
9
+ import type { Backend } from "../backend-registry/types";
10
+ import { callCloudProxy } from "./proxy";
11
+
12
+ /**
13
+ * Cloud transport for the Agent Profiles surface. The cloud enterprise
14
+ * app-server exposes the SAME `/api/agent-profiles` contract as the local
15
+ * agent-server (paths + response shapes mirror `agent_profiles_router.py`),
16
+ * but cloud calls authenticate with the backend's bearer token + `X-Org-Id`
17
+ * and must go through {@link callCloudProxy} — not the direct
18
+ * `AgentProfilesClient` (which speaks `X-Session-API-Key` to a local host).
19
+ *
20
+ * The org is resolved server-side from the auth session (`EFFECTIVE_ORG_ID`),
21
+ * so — unlike org LLM profiles — no `{org_id}` path segment is needed here.
22
+ *
23
+ * Mirrors the pattern in `cloud/settings-service.api.ts`. Consumed by
24
+ * `AgentProfilesService` on the cloud path.
25
+ */
26
+
27
+ const BASE = "/api/agent-profiles";
28
+
29
+ function activeCloudBackend(): Backend {
30
+ const active = getActiveBackend().backend;
31
+ if (active.kind !== "cloud") {
32
+ throw new Error("Cloud agent-profile call requires a cloud backend.");
33
+ }
34
+ return active;
35
+ }
36
+
37
+ export async function listCloudAgentProfiles(): Promise<AgentProfileListResponse> {
38
+ return callCloudProxy<AgentProfileListResponse>({
39
+ backend: activeCloudBackend(),
40
+ method: "GET",
41
+ path: BASE,
42
+ });
43
+ }
44
+
45
+ export async function getCloudAgentProfile(
46
+ name: string,
47
+ ): Promise<AgentProfileDetailResponse> {
48
+ return callCloudProxy<AgentProfileDetailResponse>({
49
+ backend: activeCloudBackend(),
50
+ method: "GET",
51
+ path: `${BASE}/${encodeURIComponent(name)}`,
52
+ });
53
+ }
54
+
55
+ export async function saveCloudAgentProfile(
56
+ name: string,
57
+ profile: AgentProfileSaveInput,
58
+ ): Promise<AgentProfileMutationResponse> {
59
+ return callCloudProxy<AgentProfileMutationResponse>({
60
+ backend: activeCloudBackend(),
61
+ method: "POST",
62
+ path: `${BASE}/${encodeURIComponent(name)}`,
63
+ body: profile,
64
+ });
65
+ }
66
+
67
+ export async function deleteCloudAgentProfile(
68
+ name: string,
69
+ ): Promise<AgentProfileMutationResponse> {
70
+ return callCloudProxy<AgentProfileMutationResponse>({
71
+ backend: activeCloudBackend(),
72
+ method: "DELETE",
73
+ path: `${BASE}/${encodeURIComponent(name)}`,
74
+ });
75
+ }
76
+
77
+ export async function renameCloudAgentProfile(
78
+ name: string,
79
+ newName: string,
80
+ ): Promise<AgentProfileMutationResponse> {
81
+ return callCloudProxy<AgentProfileMutationResponse>({
82
+ backend: activeCloudBackend(),
83
+ method: "POST",
84
+ path: `${BASE}/${encodeURIComponent(name)}/rename`,
85
+ body: { new_name: newName },
86
+ });
87
+ }
88
+
89
+ export async function activateCloudAgentProfile(
90
+ profileId: string,
91
+ ): Promise<ActivateAgentProfileResponse> {
92
+ return callCloudProxy<ActivateAgentProfileResponse>({
93
+ backend: activeCloudBackend(),
94
+ method: "POST",
95
+ path: `${BASE}/${encodeURIComponent(profileId)}/activate`,
96
+ body: {},
97
+ });
98
+ }
src/api/cloud/client.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CloudClient } from "@openhands/typescript-client/clients";
2
+ import {
3
+ getAgentServerBaseUrl,
4
+ getAgentServerHeaders,
5
+ } from "../agent-server-config";
6
+ import { NoBackendAvailableError } from "../agent-server-client-options";
7
+ import { getActiveBackend } from "../backend-registry/active-store";
8
+ import type { Backend } from "../backend-registry/types";
9
+
10
+ function requireCloudBackend(backend?: Backend): Backend {
11
+ if (backend) return backend;
12
+ const active = getActiveBackend().backend;
13
+ if (active.kind !== "cloud") {
14
+ throw new Error("Cloud calls require a cloud backend.");
15
+ }
16
+ return active;
17
+ }
18
+
19
+ /**
20
+ * Send `X-Org-Id` so the upstream scopes per-request to the org the user
21
+ * selected locally, instead of the user's globally-shared `current_org_id`
22
+ * on the cloud backend. Restricted to calls against the active backend: the
23
+ * selector also fans out per-backend bookkeeping calls (e.g.
24
+ * `getCloudOrganizations(b)`) that would otherwise carry the active
25
+ * backend's orgId across an unrelated API key, which the cloud backend
26
+ * rejects when api_key_org_id and X-Org-Id disagree.
27
+ */
28
+ function activeOrgForBackend(backend: Backend): string | null {
29
+ const active = getActiveBackend();
30
+ return active.backend.id === backend.id ? active.orgId : null;
31
+ }
32
+
33
+ export function createCloudClient(backend?: Backend): CloudClient {
34
+ const target = requireCloudBackend(backend);
35
+ const proxyBaseUrl = getAgentServerBaseUrl();
36
+ const proxyHeaders = proxyBaseUrl ? getAgentServerHeaders() : {};
37
+
38
+ return new CloudClient({
39
+ host: target.host,
40
+ apiKey: target.apiKey,
41
+ orgId: activeOrgForBackend(target),
42
+ // Default request timeout in ms, matching the 30s the previous axios
43
+ // transport used for direct and proxied calls. Per-request
44
+ // `timeoutSeconds` still overrides it for direct calls.
45
+ timeout: 30_000,
46
+ ...(proxyBaseUrl
47
+ ? {
48
+ proxy: {
49
+ host: proxyBaseUrl,
50
+ headers: proxyHeaders,
51
+ },
52
+ }
53
+ : {}),
54
+ });
55
+ }
56
+
57
+ export function createCloudClientForRuntime(backend?: Backend): CloudClient {
58
+ const client = createCloudClient(backend);
59
+ if (!client.proxy) {
60
+ throw new NoBackendAvailableError();
61
+ }
62
+ return client;
63
+ }
src/api/cloud/conversation-service.api.ts ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getActiveBackend,
3
+ getRegisteredBackends,
4
+ } from "../backend-registry/active-store";
5
+ import type { Backend } from "../backend-registry/types";
6
+ import { getStoredConversationMetadata } from "../conversation-metadata-store";
7
+ import type {
8
+ AppConversation,
9
+ AppConversationPage,
10
+ AppConversationStartRequest,
11
+ AppConversationStartTask,
12
+ } from "../conversation-service/agent-server-conversation-service.types";
13
+ import { AGENT_CANVAS_CLIENT_HEADERS } from "../client-source";
14
+ import { callCloudProxy } from "./proxy";
15
+
16
+ /**
17
+ * The cloud backend does not always echo `selected_repository` /
18
+ * `selected_branch` / `git_provider` back from
19
+ * `GET /api/v1/app-conversations` until its own background hydration
20
+ * completes. We persist the selection to local storage at connect time
21
+ * (see `AgentServerConversationService.updateConversationRepository`)
22
+ * and overlay it here so the chat-page git control bar reflects the
23
+ * connection immediately, instead of snapping back to the empty
24
+ * "Connect Repo" state on every refetch.
25
+ *
26
+ * Server values take precedence whenever they're populated; the
27
+ * local-storage fallback only fills in fields the server returned as
28
+ * `null`/`undefined`.
29
+ */
30
+ function overlayStoredRepoSelection(
31
+ conversation: AppConversation | null,
32
+ ): AppConversation | null {
33
+ if (!conversation?.id) return conversation;
34
+ const stored = getStoredConversationMetadata(conversation.id);
35
+ if (!stored) return conversation;
36
+
37
+ return {
38
+ ...conversation,
39
+ selected_repository:
40
+ conversation.selected_repository ?? stored.selected_repository ?? null,
41
+ selected_branch:
42
+ conversation.selected_branch ?? stored.selected_branch ?? null,
43
+ git_provider: conversation.git_provider ?? stored.git_provider ?? null,
44
+ selected_workspace:
45
+ conversation.selected_workspace ?? stored.selected_workspace ?? null,
46
+ };
47
+ }
48
+
49
+ function getActiveCloudBackend(): Backend {
50
+ const active = getActiveBackend().backend;
51
+ if (active.kind !== "cloud") {
52
+ throw new Error("Cloud conversations call requires a cloud backend.");
53
+ }
54
+ return active;
55
+ }
56
+
57
+ /**
58
+ * Resolve the cloud backend a Cloud conversation should be launched against
59
+ * when the active backend may not be a cloud one. Cloud conversations cannot
60
+ * register client tools, so the typed launch action always runs from a local
61
+ * parent and has to reach a registered-but-inactive cloud backend.
62
+ *
63
+ * Prefers the active backend when it is already cloud, then the first
64
+ * registered cloud backend that carries credentials. Returns `null` when the
65
+ * user has no cloud backend connected, so callers can give the agent
66
+ * corrective guidance instead of failing opaquely.
67
+ *
68
+ * Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so
69
+ * a conversation launched against an inactive backend lands in the API key's
70
+ * own organization.
71
+ */
72
+ export function pickCloudBackendForLaunch(): Backend | null {
73
+ const active = getActiveBackend().backend;
74
+ if (active.kind === "cloud") return active;
75
+ return (
76
+ getRegisteredBackends().find(
77
+ (backend) => backend.kind === "cloud" && !!backend.apiKey,
78
+ ) ?? null
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Search the cloud app-conversations list. Mirrors the local
84
+ * `AgentServerConversationService.searchConversations` interface but calls
85
+ * the cloud endpoint `/api/v1/app-conversations/search`.
86
+ */
87
+ export async function searchCloudConversations(
88
+ limit: number = 20,
89
+ pageId?: string,
90
+ ): Promise<AppConversationPage> {
91
+ const backend = getActiveCloudBackend();
92
+ const params = new URLSearchParams();
93
+ params.set("limit", String(limit));
94
+ if (pageId) params.set("page_id", pageId);
95
+ params.set("sort_order", "UPDATED_AT_DESC");
96
+
97
+ const data = await callCloudProxy<{
98
+ items: AppConversation[];
99
+ next_page_id: string | null;
100
+ }>({
101
+ backend,
102
+ method: "GET",
103
+ path: `/api/v1/app-conversations/search?${params.toString()}`,
104
+ });
105
+
106
+ return {
107
+ items: (data?.items ?? []).map(
108
+ (item) => overlayStoredRepoSelection(item) as AppConversation,
109
+ ),
110
+ next_page_id: data?.next_page_id ?? null,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Batch-fetch cloud app-conversations by id. Mirrors the local
116
+ * `AgentServerConversationService.batchGetAppConversations` interface.
117
+ */
118
+ export async function batchGetCloudConversations(
119
+ ids: string[],
120
+ ): Promise<(AppConversation | null)[]> {
121
+ if (ids.length === 0) return [];
122
+ const backend = getActiveCloudBackend();
123
+ const params = new URLSearchParams();
124
+ for (const id of ids) params.append("ids", id);
125
+ const data = await callCloudProxy<(AppConversation | null)[]>({
126
+ backend,
127
+ method: "GET",
128
+ path: `/api/v1/app-conversations?${params.toString()}`,
129
+ });
130
+ return (data ?? []).map(overlayStoredRepoSelection);
131
+ }
132
+
133
+ /**
134
+ * Create a v1 app-conversation on the cloud backend.
135
+ *
136
+ * Mirrors OpenHands' cloud flow: POST /api/v1/app-conversations with the
137
+ * `AppConversationStartRequest` payload, returning a
138
+ * `AppConversationStartTask`. The task is initially WORKING; the caller
139
+ * polls `getCloudAppConversationStartTask` (3s cadence per OpenHands)
140
+ * until status is READY (then `app_conversation_id`, `agent_server_url`,
141
+ * and `session_api_key` are populated) or ERROR.
142
+ *
143
+ * This path does NOT use encrypted-settings round-tripping. Secrets stay
144
+ * server-side on the cloud backend — the only auth carried is the cloud bearer
145
+ * token, and the conversation runtime is
146
+ * provisioned with its own ephemeral session_api_key returned in the
147
+ * task.
148
+ */
149
+ export async function createCloudAppConversation(
150
+ request: AppConversationStartRequest,
151
+ // Defaults to the active backend. The typed launch action passes an explicit
152
+ // backend because it always runs from a *local* parent conversation.
153
+ backendOverride?: Backend,
154
+ ): Promise<AppConversationStartTask> {
155
+ const backend = backendOverride ?? getActiveCloudBackend();
156
+ const data = await callCloudProxy<AppConversationStartTask>({
157
+ backend,
158
+ method: "POST",
159
+ path: "/api/v1/app-conversations",
160
+ body: request as unknown as Record<string, unknown>,
161
+ headers: AGENT_CANVAS_CLIENT_HEADERS,
162
+ });
163
+ return data;
164
+ }
165
+
166
+ /**
167
+ * Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors
168
+ * the local `AgentServerConversationService.downloadConversation` interface but
169
+ * calls
170
+ * `GET /api/v1/app-conversations/{id}/download`, which returns
171
+ * `application/zip` with `Content-Disposition` set by the cloud backend.
172
+ */
173
+ export async function downloadCloudConversation(
174
+ conversationId: string,
175
+ ): Promise<Blob> {
176
+ const backend = getActiveCloudBackend();
177
+ return callCloudProxy<Blob>({
178
+ backend,
179
+ method: "GET",
180
+ path: `/api/v1/app-conversations/${conversationId}/download`,
181
+ responseType: "blob",
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Delete a v1 app-conversation on the cloud backend. Mirrors the local
187
+ * `AgentServerConversationService.deleteConversation` interface but calls
188
+ * `DELETE /api/v1/app-conversations/{id}`, which returns a JSON
189
+ * `Success` envelope (discarded here — the caller only needs to know
190
+ * the request didn't error).
191
+ */
192
+ export async function deleteCloudConversation(
193
+ conversationId: string,
194
+ ): Promise<void> {
195
+ const backend = getActiveCloudBackend();
196
+ await callCloudProxy<unknown>({
197
+ backend,
198
+ method: "DELETE",
199
+ path: `/api/v1/app-conversations/${conversationId}`,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors
205
+ * OpenHands' `AgentServerConversationService.updateConversationPublicFlag`:
206
+ * `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning
207
+ * the updated conversation.
208
+ */
209
+ export async function updateCloudConversationPublicFlag(
210
+ conversationId: string,
211
+ isPublic: boolean,
212
+ ): Promise<AppConversation> {
213
+ const backend = getActiveCloudBackend();
214
+ const data = await callCloudProxy<AppConversation>({
215
+ backend,
216
+ method: "PATCH",
217
+ path: `/api/v1/app-conversations/${conversationId}`,
218
+ body: { public: isPublic },
219
+ });
220
+ return data;
221
+ }
222
+
223
+ /**
224
+ * Rename a cloud v1 app-conversation. The title belongs to the Cloud
225
+ * app-conversation resource, so updating a runtime Agent Server would not
226
+ * persist it in the Cloud conversation list.
227
+ */
228
+ export async function updateCloudConversationTitle(
229
+ conversationId: string,
230
+ title: string,
231
+ ): Promise<AppConversation> {
232
+ const backend = getActiveCloudBackend();
233
+ return callCloudProxy<AppConversation>({
234
+ backend,
235
+ method: "PATCH",
236
+ path: `/api/v1/app-conversations/${conversationId}`,
237
+ body: { title },
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Pause the cloud sandbox backing a v1 app-conversation. Mirrors
243
+ * OpenHands' `SandboxService.pauseSandbox`:
244
+ * `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops
245
+ * the runtime owning the conversation.
246
+ */
247
+ export async function pauseCloudSandbox(sandboxId: string): Promise<void> {
248
+ const backend = getActiveCloudBackend();
249
+ await callCloudProxy<unknown>({
250
+ backend,
251
+ method: "POST",
252
+ path: `/api/v1/sandboxes/${sandboxId}/pause`,
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox`
258
+ * by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS.
259
+ *
260
+ * This is the correct endpoint for waking a PAUSED sandbox. It is a
261
+ * lightweight unpause — NOT the same as creating a new start task via
262
+ * `POST /api/v1/app-conversations`, which provisions a fresh conversation
263
+ * and is subject to the 120-second sandbox-start timeout.
264
+ */
265
+ export async function resumeCloudSandbox(sandboxId: string): Promise<void> {
266
+ const backend = getActiveCloudBackend();
267
+ await callCloudProxy<unknown>({
268
+ backend,
269
+ method: "POST",
270
+ path: `/api/v1/sandboxes/${sandboxId}/resume`,
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Read a file from a cloud conversation's sandbox workspace. Mirrors
276
+ * OpenHands' `AgentServerConversationService.readConversationFile` — hits
277
+ * `GET /api/v1/app-conversations/{id}/file?file_path=...` on the cloud backend
278
+ * and returns the file content as a string.
279
+ */
280
+ export async function readCloudConversationFile(
281
+ conversationId: string,
282
+ filePath: string,
283
+ ): Promise<string> {
284
+ const backend = getActiveCloudBackend();
285
+ const params = new URLSearchParams();
286
+ params.append("file_path", filePath);
287
+ const data = await callCloudProxy<string>({
288
+ backend,
289
+ method: "GET",
290
+ path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`,
291
+ });
292
+ return data ?? "";
293
+ }
294
+
295
+ /**
296
+ * List every file in a cloud conversation's sandbox workspace. Hits
297
+ * `GET /api/v1/app-conversations/{id}/files?path=...` on the cloud backend,
298
+ * which resolves the conversation's runtime and runs a bounded `find`
299
+ * server-side (see enterprise `list_conversation_files`). Unlike the
300
+ * git-changes source, this returns the full tree so the Files tab matches the
301
+ * local-backend experience. Paths come back relative to `path`.
302
+ */
303
+ export async function listCloudConversationFiles(
304
+ conversationId: string,
305
+ path: string,
306
+ ): Promise<string[]> {
307
+ const backend = getActiveCloudBackend();
308
+ const params = new URLSearchParams();
309
+ params.append("path", path);
310
+ const data = await callCloudProxy<string[]>({
311
+ backend,
312
+ method: "GET",
313
+ path: `/api/v1/app-conversations/${conversationId}/files?${params.toString()}`,
314
+ });
315
+ return Array.isArray(data) ? data : [];
316
+ }
317
+
318
+ /**
319
+ * Fetch a single v1 app-conversation start task. Mirrors OpenHands'
320
+ * `AgentServerConversationService.getStartTask` — uses the batch search endpoint
321
+ * with a single id and unwraps the first result.
322
+ */
323
+ export async function getCloudAppConversationStartTask(
324
+ taskId: string,
325
+ backendOverride?: Backend,
326
+ ): Promise<AppConversationStartTask | null> {
327
+ const backend = backendOverride ?? getActiveCloudBackend();
328
+ const params = new URLSearchParams();
329
+ params.set("ids", taskId);
330
+ const data = await callCloudProxy<(AppConversationStartTask | null)[]>({
331
+ backend,
332
+ method: "GET",
333
+ path: `/api/v1/app-conversations/start-tasks?${params.toString()}`,
334
+ });
335
+ return data?.[0] ?? null;
336
+ }
src/api/cloud/git-service.api.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ BranchPage,
3
+ GitRepository,
4
+ InstallationPage,
5
+ RepositoryPage,
6
+ } from "#/types/git";
7
+ import type { Provider } from "#/types/settings";
8
+ import { getActiveBackend } from "../backend-registry/active-store";
9
+ import type { Backend } from "../backend-registry/types";
10
+ import { callCloudProxy } from "./proxy";
11
+
12
+ function getActiveCloudBackend(): Backend {
13
+ const active = getActiveBackend().backend;
14
+ if (active.kind !== "cloud") {
15
+ throw new Error("Cloud git call requires a cloud backend.");
16
+ }
17
+ return active;
18
+ }
19
+
20
+ export async function searchCloudRepositories(args: {
21
+ provider: Provider;
22
+ query?: string;
23
+ limit?: number;
24
+ pageId?: string;
25
+ installationId?: string;
26
+ }): Promise<RepositoryPage> {
27
+ const backend = getActiveCloudBackend();
28
+ const params = new URLSearchParams();
29
+ params.set("provider", args.provider);
30
+ params.set("limit", String(args.limit ?? 100));
31
+ if (args.query) params.set("query", args.query);
32
+ if (args.pageId) params.set("page_id", args.pageId);
33
+ if (args.installationId) params.set("installation_id", args.installationId);
34
+
35
+ const data = await callCloudProxy<{
36
+ items: GitRepository[];
37
+ next_page_id: string | null;
38
+ }>({
39
+ backend,
40
+ method: "GET",
41
+ path: `/api/v1/git/repositories/search?${params.toString()}`,
42
+ });
43
+
44
+ return {
45
+ items: data?.items ?? [],
46
+ next_page_id: data?.next_page_id ?? null,
47
+ };
48
+ }
49
+
50
+ export async function getCloudInstallations(args: {
51
+ provider: Provider;
52
+ pageId?: string;
53
+ limit?: number;
54
+ }): Promise<InstallationPage> {
55
+ const backend = getActiveCloudBackend();
56
+ const params = new URLSearchParams();
57
+ params.set("provider", args.provider);
58
+ params.set("limit", String(args.limit ?? 100));
59
+ if (args.pageId) params.set("page_id", args.pageId);
60
+
61
+ const data = await callCloudProxy<{
62
+ items: string[];
63
+ next_page_id: string | null;
64
+ }>({
65
+ backend,
66
+ method: "GET",
67
+ path: `/api/v1/git/installations/search?${params.toString()}`,
68
+ });
69
+
70
+ return {
71
+ items: data?.items ?? [],
72
+ next_page_id: data?.next_page_id ?? null,
73
+ };
74
+ }
75
+
76
+ export async function getCloudRepositoryBranches(args: {
77
+ provider: Provider;
78
+ repository: string;
79
+ query?: string;
80
+ pageId?: string;
81
+ limit?: number;
82
+ }): Promise<BranchPage> {
83
+ const backend = getActiveCloudBackend();
84
+ const params = new URLSearchParams();
85
+ params.set("provider", args.provider);
86
+ params.set("repository", args.repository);
87
+ params.set("limit", String(args.limit ?? 30));
88
+ params.set("query", args.query ?? "");
89
+ if (args.pageId) params.set("page_id", args.pageId);
90
+
91
+ const data = await callCloudProxy<{
92
+ items: BranchPage["items"];
93
+ next_page_id: string | null;
94
+ }>({
95
+ backend,
96
+ method: "GET",
97
+ path: `/api/v1/git/branches/search?${params.toString()}`,
98
+ });
99
+
100
+ return {
101
+ items: data?.items ?? [],
102
+ next_page_id: data?.next_page_id ?? null,
103
+ };
104
+ }
src/api/cloud/organization-service.api.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from "@openhands/typescript-client";
2
+ import { getActiveBackend } from "../backend-registry/active-store";
3
+ import type { Backend } from "../backend-registry/types";
4
+ import { callCloudProxy } from "./proxy";
5
+ import type {
6
+ CloudApiKeyMetadata,
7
+ CloudOrganization,
8
+ CloudOrganizationMember,
9
+ CloudOrganizationsResponse,
10
+ } from "./types";
11
+
12
+ interface OrganizationsResult {
13
+ items: CloudOrganization[];
14
+ currentOrgId: string | null;
15
+ }
16
+
17
+ function normalizeResult(
18
+ data: CloudOrganizationsResponse | undefined | null,
19
+ ): OrganizationsResult {
20
+ return {
21
+ items: data?.items ?? [],
22
+ currentOrgId: data?.current_org_id ?? null,
23
+ };
24
+ }
25
+
26
+ function resolveBackend(backend?: Backend): Backend {
27
+ if (backend) return backend;
28
+ const active = getActiveBackend().backend;
29
+ if (active.kind !== "cloud") {
30
+ throw new Error(
31
+ "Cloud organization calls require a cloud backend. Active backend is local.",
32
+ );
33
+ }
34
+ return active;
35
+ }
36
+
37
+ /**
38
+ * Fetch the org list for a cloud backend. With no argument, uses the active
39
+ * cloud backend; pass `backend` explicitly to fetch for an inactive cloud
40
+ * (used by the selector to flatten all cloud rows).
41
+ *
42
+ * Calls the cloud API directly with the backend's bearer token.
43
+ */
44
+ export async function getCloudOrganizations(
45
+ backend?: Backend,
46
+ ): Promise<OrganizationsResult> {
47
+ const target = resolveBackend(backend);
48
+ const data = await callCloudProxy<CloudOrganizationsResponse>({
49
+ backend: target,
50
+ method: "GET",
51
+ path: "/api/organizations",
52
+ });
53
+ return normalizeResult(data);
54
+ }
55
+
56
+ /**
57
+ * Fetch metadata for the API key used to authenticate this cloud backend.
58
+ * The returned `orgId` is the single org the key is authorized to act on
59
+ * (the cloud contract: one key → one org).
60
+ *
61
+ * Legacy keys minted before per-key org binding existed cause the upstream
62
+ * to return HTTP 400 — we surface that as `isLegacyKey: true` with a null
63
+ * `orgId` so the caller can fall back to the unfiltered behavior. Other
64
+ * statuses (401 revoked, 5xx outage) propagate so React Query can mark
65
+ * the query as failed and the selector can render the no-org-known row.
66
+ */
67
+ export async function getCurrentCloudApiKey(
68
+ backend?: Backend,
69
+ ): Promise<{ orgId: string | null; isLegacyKey: boolean }> {
70
+ const target = resolveBackend(backend);
71
+ try {
72
+ const data = await callCloudProxy<CloudApiKeyMetadata>({
73
+ backend: target,
74
+ method: "GET",
75
+ path: "/api/keys/current",
76
+ });
77
+ return { orgId: data?.org_id ?? null, isLegacyKey: false };
78
+ } catch (e) {
79
+ if (e instanceof HttpError && e.status === 400) {
80
+ return { orgId: null, isLegacyKey: true };
81
+ }
82
+ throw e;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Fetch `GET /api/organizations/{orgId}/me`. Identifies the calling user as
88
+ * a member of `orgId`. The GUI uses `me.org_id === me.user_id` to decide
89
+ * whether `orgId` is the user's personal workspace — that's the cloud
90
+ * contract (the auto-generated personal-workspace org has the same id as
91
+ * the user).
92
+ *
93
+ * `role` is the caller's role in the org (`owner` | `admin` | `member`, or
94
+ * `null` if the upstream omits it). `permissions` is the server-defined
95
+ * permission set for that role (e.g. `edit_org_settings`); it is `null` on
96
+ * older app-servers that don't return it, so callers fall back to the role.
97
+ * See `useCanManageOrgProfiles`.
98
+ */
99
+ export async function getCloudOrganizationMe(
100
+ orgId: string,
101
+ backend?: Backend,
102
+ ): Promise<{
103
+ orgId: string;
104
+ userId: string;
105
+ role: string | null;
106
+ permissions?: string[] | null;
107
+ }> {
108
+ const target = resolveBackend(backend);
109
+ const data = await callCloudProxy<{
110
+ org_id: string;
111
+ user_id: string;
112
+ role?: string;
113
+ permissions?: string[];
114
+ }>({
115
+ backend: target,
116
+ method: "GET",
117
+ path: `/api/organizations/${encodeURIComponent(orgId)}/me`,
118
+ });
119
+ return {
120
+ orgId: data?.org_id ?? orgId,
121
+ userId: data?.user_id ?? "",
122
+ role: data?.role ?? null,
123
+ // `null` when the field is absent (older app-server) so callers can fall
124
+ // back to a role check; a present array is the server's source of truth.
125
+ permissions: Array.isArray(data?.permissions) ? data.permissions : null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Fetch `GET /api/organizations/{orgId}/members/{userId}`: one member of
131
+ * `orgId` by user id. Any org member may read it (same permission as the
132
+ * members list). The upstream responds 404 when `userId` is not (or no
133
+ * longer) a member of the org, and older app-servers that predate the route
134
+ * 404 as well, so callers should treat a failure as "identity unresolved".
135
+ */
136
+ export async function getCloudOrganizationMember(
137
+ orgId: string,
138
+ userId: string,
139
+ backend?: Backend,
140
+ ): Promise<CloudOrganizationMember> {
141
+ const target = resolveBackend(backend);
142
+ return callCloudProxy<CloudOrganizationMember>({
143
+ backend: target,
144
+ method: "GET",
145
+ path: `/api/organizations/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`,
146
+ });
147
+ }
src/api/cloud/profiles-service.api.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ActivateProfileResponse,
3
+ ProfileDetailResponse,
4
+ ProfileListResponse,
5
+ ProfileMutationResponse,
6
+ SaveProfileRequest,
7
+ } from "@openhands/typescript-client";
8
+ import { getActiveBackend } from "../backend-registry/active-store";
9
+ import type { Backend } from "../backend-registry/types";
10
+ import { callCloudProxy } from "./proxy";
11
+
12
+ /**
13
+ * Cloud LLM-profile service.
14
+ *
15
+ * Profile CRUD is routed to the org-scoped endpoints
16
+ * `/api/organizations/{orgId}/profiles`, which enforce `EDIT_ORG_SETTINGS`
17
+ * server-side — so a member's mutation is rejected with 403 even on a direct
18
+ * API call, not just hidden in the UI. When the active cloud backend has no org
19
+ * bound (legacy API keys), we fall back to the ungated per-user settings route
20
+ * `/api/v1/settings/profiles`, where there is no org role to enforce against.
21
+ *
22
+ * The two routes share shapes except `get` (org returns `llm`, settings returns
23
+ * `config`) and `activate` (org returns `llm`, settings returns `model`); both
24
+ * are normalized to the SDK profile types below. Neither route exposes secrets.
25
+ */
26
+
27
+ const SETTINGS_PROFILES_PATH = "/api/v1/settings/profiles";
28
+
29
+ /**
30
+ * Resolve the backend + base path for the active cloud backend's profiles:
31
+ * the org-gated route when an org is bound, else the per-user settings route.
32
+ */
33
+ function cloudProfilesTarget(): { backend: Backend; base: string } {
34
+ const { backend, orgId } = getActiveBackend();
35
+ if (backend.kind !== "cloud") {
36
+ throw new Error("Cloud profiles call requires a cloud backend.");
37
+ }
38
+ return {
39
+ backend,
40
+ base: orgId
41
+ ? `/api/organizations/${encodeURIComponent(orgId)}/profiles`
42
+ : SETTINGS_PROFILES_PATH,
43
+ };
44
+ }
45
+
46
+ export async function fetchCloudProfiles(): Promise<ProfileListResponse> {
47
+ const { backend, base } = cloudProfilesTarget();
48
+ return callCloudProxy<ProfileListResponse>({
49
+ backend,
50
+ method: "GET",
51
+ path: base,
52
+ });
53
+ }
54
+
55
+ export async function fetchCloudProfile(
56
+ name: string,
57
+ ): Promise<ProfileDetailResponse> {
58
+ const { backend, base } = cloudProfilesTarget();
59
+ // Org returns `{ name, llm }`; settings returns `{ name, config, api_key_set }`.
60
+ // Normalize to the SDK detail shape. Neither route exposes the key, and the
61
+ // GUI never reads the detail's `api_key_set` (only list-item `api_key_set`).
62
+ const result = await callCloudProxy<{
63
+ name: string;
64
+ config?: Record<string, unknown>;
65
+ llm?: Record<string, unknown>;
66
+ api_key_set?: boolean;
67
+ }>({
68
+ backend,
69
+ method: "GET",
70
+ path: `${base}/${encodeURIComponent(name)}`,
71
+ });
72
+ return {
73
+ name: result.name,
74
+ config: result.config ?? result.llm ?? {},
75
+ api_key_set: result.api_key_set ?? false,
76
+ };
77
+ }
78
+
79
+ export async function saveCloudProfile(
80
+ name: string,
81
+ request: SaveProfileRequest,
82
+ ): Promise<ProfileMutationResponse> {
83
+ const { backend, base } = cloudProfilesTarget();
84
+ return callCloudProxy<ProfileMutationResponse>({
85
+ backend,
86
+ method: "POST",
87
+ path: `${base}/${encodeURIComponent(name)}`,
88
+ body: request,
89
+ });
90
+ }
91
+
92
+ export async function deleteCloudProfile(
93
+ name: string,
94
+ ): Promise<ProfileMutationResponse> {
95
+ const { backend, base } = cloudProfilesTarget();
96
+ return callCloudProxy<ProfileMutationResponse>({
97
+ backend,
98
+ method: "DELETE",
99
+ path: `${base}/${encodeURIComponent(name)}`,
100
+ });
101
+ }
102
+
103
+ export async function renameCloudProfile(
104
+ name: string,
105
+ newName: string,
106
+ ): Promise<ProfileMutationResponse> {
107
+ const { backend, base } = cloudProfilesTarget();
108
+ return callCloudProxy<ProfileMutationResponse>({
109
+ backend,
110
+ method: "POST",
111
+ path: `${base}/${encodeURIComponent(name)}/rename`,
112
+ body: { new_name: newName },
113
+ });
114
+ }
115
+
116
+ export async function activateCloudProfile(
117
+ name: string,
118
+ ): Promise<ActivateProfileResponse> {
119
+ const { backend, base } = cloudProfilesTarget();
120
+ // Org returns `{ name, message, llm }`; settings returns `{ name, message,
121
+ // model }`. The SDK type carries `llm_applied`; derive it from whichever the
122
+ // route provided (consumers only read name/message — the hook just
123
+ // invalidates caches).
124
+ const result = await callCloudProxy<{
125
+ name: string;
126
+ message: string;
127
+ model?: string | null;
128
+ llm?: Record<string, unknown> | null;
129
+ }>({
130
+ backend,
131
+ method: "POST",
132
+ path: `${base}/${encodeURIComponent(name)}/activate`,
133
+ body: {},
134
+ });
135
+ return {
136
+ name: result.name,
137
+ message: result.message,
138
+ llm_applied: result.model != null || result.llm != null,
139
+ };
140
+ }
src/api/cloud/provider-connections-service.api.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ProviderConnection,
3
+ CreateProviderConnectionRequest,
4
+ UpdateProviderConnectionRequest,
5
+ } from "../provider-connections-service/provider-connections-service.api";
6
+ import { getActiveBackend } from "../backend-registry/active-store";
7
+ import type { Backend } from "../backend-registry/types";
8
+ import { callCloudProxy } from "./proxy";
9
+
10
+ /**
11
+ * Cloud provider-connections service.
12
+ *
13
+ * Provider-connection CRUD is routed to the org-scoped endpoints
14
+ * `/api/organizations/{orgId}/provider-connections`, which enforce
15
+ * `EDIT_ORG_SETTINGS` server-side (listing requires `VIEW_ORG_SETTINGS`) — so a
16
+ * member's mutation is rejected with 403 even on a direct API call, not just
17
+ * hidden in the UI. This mirrors the cloud LLM-profile service.
18
+ *
19
+ * Unlike the local agent-server (which returns a bare array), the org list
20
+ * route wraps results as `{ connections: [...] }`; this service unwraps that so
21
+ * callers see the same `ProviderConnection[]` shape on both backends. Individual
22
+ * mutations return a bare `ProviderConnection` on both backends. Neither route
23
+ * exposes the stored key; each item carries `api_key_set` instead.
24
+ *
25
+ * Cloud has no unbound-org fallback here: provider connections are inherently
26
+ * org-scoped, so an org must be bound. A cloud backend without an org id is a
27
+ * programming error rather than a legacy-key case.
28
+ */
29
+
30
+ interface CloudProviderConnectionListResponse {
31
+ connections: ProviderConnection[];
32
+ }
33
+
34
+ /** Resolve the backend + org-scoped base path for the active cloud backend. */
35
+ function cloudProviderConnectionsTarget(): { backend: Backend; base: string } {
36
+ const { backend, orgId } = getActiveBackend();
37
+ if (backend.kind !== "cloud") {
38
+ throw new Error(
39
+ "Cloud provider-connections call requires a cloud backend.",
40
+ );
41
+ }
42
+ if (!orgId) {
43
+ throw new Error(
44
+ "Cloud provider connections require an organization-bound backend.",
45
+ );
46
+ }
47
+ return {
48
+ backend,
49
+ base: `/api/organizations/${encodeURIComponent(orgId)}/provider-connections`,
50
+ };
51
+ }
52
+
53
+ export async function fetchCloudProviderConnections(): Promise<
54
+ ProviderConnection[]
55
+ > {
56
+ const { backend, base } = cloudProviderConnectionsTarget();
57
+ const result = await callCloudProxy<CloudProviderConnectionListResponse>({
58
+ backend,
59
+ method: "GET",
60
+ path: base,
61
+ });
62
+ return result.connections ?? [];
63
+ }
64
+
65
+ export async function createCloudProviderConnection(
66
+ request: CreateProviderConnectionRequest,
67
+ ): Promise<ProviderConnection> {
68
+ const { backend, base } = cloudProviderConnectionsTarget();
69
+ return callCloudProxy<ProviderConnection>({
70
+ backend,
71
+ method: "POST",
72
+ path: base,
73
+ body: request,
74
+ });
75
+ }
76
+
77
+ export async function updateCloudProviderConnection(
78
+ id: string,
79
+ request: UpdateProviderConnectionRequest,
80
+ ): Promise<ProviderConnection> {
81
+ const { backend, base } = cloudProviderConnectionsTarget();
82
+ return callCloudProxy<ProviderConnection>({
83
+ backend,
84
+ method: "PATCH",
85
+ path: `${base}/${encodeURIComponent(id)}`,
86
+ body: request,
87
+ });
88
+ }
89
+
90
+ export async function deleteCloudProviderConnection(
91
+ id: string,
92
+ ): Promise<ProviderConnection> {
93
+ const { backend, base } = cloudProviderConnectionsTarget();
94
+ return callCloudProxy<ProviderConnection>({
95
+ backend,
96
+ method: "DELETE",
97
+ path: `${base}/${encodeURIComponent(id)}`,
98
+ });
99
+ }
src/api/cloud/proxy.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CloudRequestOptions } from "@openhands/typescript-client/clients";
2
+ import type { Backend } from "../backend-registry/types";
3
+ import { createCloudClientForRuntime, createCloudClient } from "./client";
4
+
5
+ export interface CloudProxyRequest {
6
+ backend: Backend;
7
+ method: CloudRequestOptions["method"];
8
+ path: string;
9
+ body?: unknown;
10
+ headers?: Record<string, string>;
11
+ timeoutSeconds?: number;
12
+ hostOverride?: string;
13
+ authMode?: "bearer" | "session-api-key" | "none";
14
+ sessionApiKey?: string | null;
15
+ responseType?: "blob";
16
+ }
17
+
18
+ export async function callCloudProxy<TResponse = unknown>(
19
+ req: CloudProxyRequest,
20
+ ): Promise<TResponse> {
21
+ const client = req.hostOverride
22
+ ? createCloudClientForRuntime(req.backend)
23
+ : createCloudClient(req.backend);
24
+
25
+ return client.request<TResponse>({
26
+ method: req.method,
27
+ path: req.path,
28
+ body: req.body,
29
+ headers: req.headers,
30
+ timeoutSeconds: req.timeoutSeconds,
31
+ hostOverride: req.hostOverride,
32
+ authMode:
33
+ req.authMode === undefined || req.authMode === "bearer"
34
+ ? "bearer"
35
+ : req.authMode,
36
+ sessionApiKey: req.sessionApiKey,
37
+ responseType: req.responseType,
38
+ });
39
+ }
src/api/cloud/sandbox-service.api.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getActiveBackend } from "../backend-registry/active-store";
2
+ import type { Backend } from "../backend-registry/types";
3
+ import { callCloudProxy } from "./proxy";
4
+ import type { V1SandboxInfo } from "./sandbox-service.types";
5
+
6
+ function getActiveCloudBackend(): Backend {
7
+ const active = getActiveBackend().backend;
8
+ if (active.kind !== "cloud") {
9
+ throw new Error("Cloud sandboxes call requires a cloud backend.");
10
+ }
11
+ return active;
12
+ }
13
+
14
+ /**
15
+ * Batch-fetch cloud sandboxes by id. Mirrors OpenHands'
16
+ * `SandboxService.batchGetSandboxes` by calling
17
+ * `GET /api/v1/sandboxes?id=...` on the cloud backend, returning each
18
+ * `SandboxInfo` (or null if not found).
19
+ *
20
+ * The returned `SandboxInfo.exposed_urls` carry the cloud-computed,
21
+ * publicly-reachable URLs for the sandbox's services (VSCODE,
22
+ * AGENT_SERVER, WORKER_*) — the GUI reads them directly instead of
23
+ * asking the runtime for `/api/vscode/url`, which only knows its
24
+ * internal localhost address.
25
+ */
26
+ export async function batchGetCloudSandboxes(
27
+ ids: string[],
28
+ ): Promise<(V1SandboxInfo | null)[]> {
29
+ if (ids.length === 0) return [];
30
+ const backend = getActiveCloudBackend();
31
+ const params = new URLSearchParams();
32
+ for (const id of ids) params.append("id", id);
33
+ const data = await callCloudProxy<(V1SandboxInfo | null)[]>({
34
+ backend,
35
+ method: "GET",
36
+ path: `/api/v1/sandboxes?${params.toString()}`,
37
+ });
38
+ return data ?? [];
39
+ }
src/api/cloud/sandbox-service.types.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type V1SandboxStatus =
2
+ | "STARTING"
3
+ | "RUNNING"
4
+ | "PAUSED"
5
+ | "ERROR"
6
+ | "MISSING";
7
+
8
+ export interface V1ExposedUrl {
9
+ name: string;
10
+ url: string;
11
+ }
12
+
13
+ export interface V1SandboxInfo {
14
+ id: string;
15
+ created_by_user_id: string | null;
16
+ sandbox_spec_id: string;
17
+ status: V1SandboxStatus;
18
+ session_api_key: string | null;
19
+ exposed_urls: V1ExposedUrl[] | null;
20
+ created_at: string;
21
+ }
src/api/cloud/secrets-service.api.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getActiveBackend } from "../backend-registry/active-store";
2
+ import type { Backend } from "../backend-registry/types";
3
+ import type { CustomSecretWithoutValue } from "../secrets-service.types";
4
+ import { withRetry } from "../with-retry";
5
+ import { callCloudProxy } from "./proxy";
6
+
7
+ interface CloudSecretsPage {
8
+ items: CustomSecretWithoutValue[];
9
+ next_page_id: string | null;
10
+ }
11
+
12
+ const PAGE_LIMIT = 100;
13
+
14
+ function getActiveCloudBackend(): Backend {
15
+ const active = getActiveBackend().backend;
16
+ if (active.kind !== "cloud") {
17
+ throw new Error("Cloud secrets call requires a cloud backend.");
18
+ }
19
+ return active;
20
+ }
21
+
22
+ /**
23
+ * Walk every page of the cloud `/api/v1/secrets/search` endpoint and return
24
+ * the merged list. The cloud shape (name + description) matches
25
+ * `CustomSecretWithoutValue`, so items pass through unchanged.
26
+ */
27
+ export async function fetchCloudSecrets(): Promise<CustomSecretWithoutValue[]> {
28
+ const backend = getActiveCloudBackend();
29
+
30
+ const secrets: CustomSecretWithoutValue[] = [];
31
+ let pageId: string | null = null;
32
+
33
+ do {
34
+ const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
35
+ if (pageId) query.set("page_id", pageId);
36
+
37
+ const page = await callCloudProxy<CloudSecretsPage>({
38
+ backend,
39
+ method: "GET",
40
+ path: `/api/v1/secrets/search?${query.toString()}`,
41
+ });
42
+
43
+ secrets.push(...(page.items ?? []));
44
+ pageId = page.next_page_id;
45
+ } while (pageId);
46
+
47
+ return secrets;
48
+ }
49
+
50
+ export interface SaveCloudSecretOptions {
51
+ /** Name the secret should have after saving. */
52
+ name: string;
53
+ /** New value. Omit to leave the stored value untouched. */
54
+ value?: string;
55
+ description?: string;
56
+ /** Name the secret is currently stored under, when editing an existing one. */
57
+ previousName?: string;
58
+ }
59
+
60
+ /**
61
+ * Create a cloud secret, or save changes to an existing one.
62
+ *
63
+ * The cloud splits a save across two endpoints, so this issues up to two
64
+ * requests to cover the whole operation:
65
+ *
66
+ * - `PUT /api/v1/secrets/{previousName}` applies the name and description. It
67
+ * is the only endpoint that can rename, and the only one that rejects a
68
+ * collision with `400`. It never touches the value.
69
+ * - `POST /api/v1/secrets` writes the value. It is a documented upsert
70
+ * ("creates a new custom secret, or overwrites it if it already exists") but
71
+ * it is keyed by the name in its body, so it cannot rename, and its `value`
72
+ * field is required, so it cannot express a metadata-only edit.
73
+ *
74
+ * The `PUT` runs first so a rejected rename fails before the value is
75
+ * overwritten — the cloud exposes no way to read a value back, so a value lost
76
+ * to a half-applied save is unrecoverable. Each request is retried on its own:
77
+ * re-running the `PUT` after a failed `POST` would `404` once the rename has
78
+ * landed.
79
+ */
80
+ export async function saveCloudSecret({
81
+ name,
82
+ value,
83
+ description,
84
+ previousName,
85
+ }: SaveCloudSecretOptions): Promise<void> {
86
+ const backend = getActiveCloudBackend();
87
+
88
+ if (previousName !== undefined) {
89
+ await withRetry(() =>
90
+ callCloudProxy<unknown>({
91
+ backend,
92
+ method: "PUT",
93
+ path: `/api/v1/secrets/${encodeURIComponent(previousName)}`,
94
+ body: { name, description },
95
+ }),
96
+ );
97
+ }
98
+
99
+ if (value !== undefined) {
100
+ await withRetry(() =>
101
+ callCloudProxy<unknown>({
102
+ backend,
103
+ method: "POST",
104
+ path: "/api/v1/secrets",
105
+ body: { name, value, description },
106
+ }),
107
+ );
108
+ }
109
+ }
110
+
111
+ export async function deleteCloudSecret(name: string): Promise<void> {
112
+ const backend = getActiveCloudBackend();
113
+ await callCloudProxy<unknown>({
114
+ backend,
115
+ method: "DELETE",
116
+ path: `/api/v1/secrets/${encodeURIComponent(name)}`,
117
+ });
118
+ }
src/api/cloud/settings-service.api.ts ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Provider,
3
+ type Settings,
4
+ type SettingsValue,
5
+ } from "#/types/settings";
6
+ import { type AppPreferences } from "../settings-service/settings-service.api";
7
+ import { getActiveBackend } from "../backend-registry/active-store";
8
+ import type { Backend } from "../backend-registry/types";
9
+ import { callCloudProxy } from "./proxy";
10
+
11
+ /**
12
+ * The cloud Settings response is mostly flat — top-level fields like
13
+ * `llm_model`, `provider_tokens_set`, etc., rather than the nested
14
+ * `{ agent_settings, conversation_settings }` shape the local agent-server
15
+ * uses. We deliberately do NOT remap cloud fields into the local shape:
16
+ * the GUI's `Settings` type already supports both layouts (it has flat
17
+ * fields AND nested `agent_settings`/`conversation_settings`), and
18
+ * cloud-aware hooks like `useUserProviders` read directly from the flat
19
+ * `provider_tokens_set` field. So the cloud response is passed through as
20
+ * a `Partial<Settings>`, with a small derivation step to also populate
21
+ * the nested fields the local-mode settings UI consumes.
22
+ */
23
+ type CloudSettingsResponse = {
24
+ llm_model?: string;
25
+ llm_base_url?: string;
26
+ llm_api_key?: string | null;
27
+ llm_api_key_set?: boolean;
28
+ search_api_key_set?: boolean;
29
+ agent?: string;
30
+ confirmation_mode?: boolean;
31
+ security_analyzer?: string | null;
32
+ max_iterations?: number | null;
33
+ enable_default_condenser?: boolean;
34
+ condenser_max_size?: number | null;
35
+ enable_proactive_conversation_starters?: boolean;
36
+ enable_solvability_analysis?: boolean;
37
+ enable_sound_notifications?: boolean;
38
+ language?: string;
39
+ email?: string;
40
+ email_verified?: boolean;
41
+ git_user_name?: string;
42
+ git_user_email?: string;
43
+ title_llm_profile?: string | null;
44
+ user_consents_to_analytics?: boolean | null;
45
+ is_new_user?: boolean;
46
+ remote_runtime_resource_factor?: number | null;
47
+ max_budget_per_task?: number | null;
48
+ provider_tokens_set?: Partial<Record<Provider, string | null>>;
49
+ mcp_config?: Record<string, SettingsValue>;
50
+ disabled_skills?: string[];
51
+ agent_settings?: Record<string, SettingsValue> | null;
52
+ conversation_settings?: Record<string, SettingsValue> | null;
53
+ agent_settings_schema?: unknown;
54
+ conversation_settings_schema?: unknown;
55
+ [key: string]: unknown;
56
+ };
57
+
58
+ function getActiveCloudBackend(): Backend {
59
+ const active = getActiveBackend().backend;
60
+ if (active.kind !== "cloud") {
61
+ throw new Error("Cloud settings call requires a cloud backend.");
62
+ }
63
+ return active;
64
+ }
65
+
66
+ /**
67
+ * Build the nested `agent_settings` block from the cloud's flat fields.
68
+ * Used so the local-mode settings page (which renders against the nested
69
+ * shape) shows the right values when the active backend is cloud.
70
+ */
71
+ function deriveAgentSettings(
72
+ flat: CloudSettingsResponse,
73
+ ): Record<string, SettingsValue> {
74
+ if (flat.agent_settings && Object.keys(flat.agent_settings).length > 0) {
75
+ return flat.agent_settings;
76
+ }
77
+ const agent: Record<string, SettingsValue> = {};
78
+ const llm: Record<string, SettingsValue> = {};
79
+ if (typeof flat.llm_model === "string") llm.model = flat.llm_model;
80
+ if (typeof flat.llm_base_url === "string") llm.base_url = flat.llm_base_url;
81
+ if (typeof flat.llm_api_key === "string") llm.api_key = flat.llm_api_key;
82
+ if (Object.keys(llm).length > 0) agent.llm = llm;
83
+
84
+ const condenser: Record<string, SettingsValue> = {};
85
+ if (typeof flat.enable_default_condenser === "boolean") {
86
+ condenser.enabled = flat.enable_default_condenser;
87
+ }
88
+ if (typeof flat.condenser_max_size === "number") {
89
+ condenser.max_size = flat.condenser_max_size;
90
+ }
91
+ if (Object.keys(condenser).length > 0) agent.condenser = condenser;
92
+
93
+ if (typeof flat.agent === "string") agent.agent = flat.agent;
94
+ if (flat.mcp_config && Object.keys(flat.mcp_config).length > 0) {
95
+ agent.mcp_config = flat.mcp_config;
96
+ }
97
+ return agent;
98
+ }
99
+
100
+ function deriveConversationSettings(
101
+ flat: CloudSettingsResponse,
102
+ ): Record<string, SettingsValue> {
103
+ if (
104
+ flat.conversation_settings &&
105
+ Object.keys(flat.conversation_settings).length > 0
106
+ ) {
107
+ return flat.conversation_settings;
108
+ }
109
+ const out: Record<string, SettingsValue> = {};
110
+ if (typeof flat.confirmation_mode === "boolean") {
111
+ out.confirmation_mode = flat.confirmation_mode;
112
+ }
113
+ if (
114
+ typeof flat.security_analyzer === "string" ||
115
+ flat.security_analyzer === null
116
+ ) {
117
+ out.security_analyzer = flat.security_analyzer;
118
+ }
119
+ if (typeof flat.max_iterations === "number") {
120
+ out.max_iterations = flat.max_iterations;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Fetch the cloud settings and return them as a `Partial<Settings>`.
127
+ *
128
+ * Top-level fields like `provider_tokens_set` are preserved unchanged so
129
+ * the existing `useUserProviders` → `useAppInstallations` →
130
+ * `useGitRepositories` chain (which reads `settings.provider_tokens_set`)
131
+ * fires correctly in cloud mode. Nested `agent_settings` /
132
+ * `conversation_settings` are derived for the settings page.
133
+ */
134
+ export async function fetchCloudSettings(): Promise<Partial<Settings>> {
135
+ const backend = getActiveCloudBackend();
136
+ const flat = await callCloudProxy<CloudSettingsResponse>({
137
+ backend,
138
+ method: "GET",
139
+ path: "/api/v1/settings",
140
+ });
141
+
142
+ return {
143
+ ...flat,
144
+ agent_settings: deriveAgentSettings(flat),
145
+ conversation_settings: deriveConversationSettings(flat),
146
+ llm_api_key_set: !!flat.llm_api_key_set,
147
+ search_api_key_set: !!flat.search_api_key_set,
148
+ provider_tokens_set: flat.provider_tokens_set,
149
+ } as Partial<Settings>;
150
+ }
151
+
152
+ export async function saveCloudSettings(diff: {
153
+ agent_settings_diff?: Record<string, SettingsValue>;
154
+ conversation_settings_diff?: Record<string, SettingsValue>;
155
+ /**
156
+ * App-level user preferences (language, sound notifications, disabled
157
+ * skills, …). The cloud `POST /api/v1/settings` consumes these as flat
158
+ * top-level fields, so this helper iterates the object and assigns each
159
+ * key onto the request body.
160
+ *
161
+ * Note: `app_preferences.disabled_skills` is the canonical source for the
162
+ * skill list since `AppPreferences` was unified in agent-server 1.27.
163
+ * Callers that still pass `disabled_skills` separately should migrate to
164
+ * setting it under `app_preferences` instead.
165
+ */
166
+ app_preferences?: AppPreferences;
167
+ }): Promise<void> {
168
+ const backend = getActiveCloudBackend();
169
+ const body: Record<string, unknown> = {};
170
+ if (diff.agent_settings_diff) {
171
+ const agentDiff: Record<string, SettingsValue> = {
172
+ ...diff.agent_settings_diff,
173
+ };
174
+ // The cloud validates agent settings against the SDK's
175
+ // OpenHandsAgentSettings, whose `agent_context` is a required
176
+ // AgentContext (not Optional). A literal `agent_context: null` fails
177
+ // backend validation, so drop it and let the backend keep/default it.
178
+ // See OpenHands/agent-canvas#981.
179
+ if (agentDiff.agent_context === null) {
180
+ delete agentDiff.agent_context;
181
+ }
182
+ if (Object.keys(agentDiff).length > 0) {
183
+ body.agent_settings_diff = agentDiff;
184
+ }
185
+ }
186
+ if (
187
+ diff.conversation_settings_diff &&
188
+ Object.keys(diff.conversation_settings_diff).length > 0
189
+ ) {
190
+ body.conversation_settings_diff = diff.conversation_settings_diff;
191
+ }
192
+ // Flat top-level app-preference fields (language, git_user_name,
193
+ // disabled_skills, …). The cloud POST /api/v1/settings stores these
194
+ // directly; see `CloudSettingsResponse` and the MSW handler in
195
+ // `src/mocks/settings-handlers.ts` for the accepted shape. `undefined`
196
+ // values are skipped so the cloud server keeps the prior value; `null`
197
+ // and empty arrays (e.g. re-enabling every skill) round-trip as
198
+ // explicit clears.
199
+ if (diff.app_preferences) {
200
+ for (const [key, value] of Object.entries(diff.app_preferences)) {
201
+ if (value !== undefined) {
202
+ body[key] = value;
203
+ }
204
+ }
205
+ }
206
+ await callCloudProxy<unknown>({
207
+ backend,
208
+ method: "POST",
209
+ path: "/api/v1/settings",
210
+ body,
211
+ });
212
+ }
213
+
214
+ export async function fetchCloudSettingsSchema(): Promise<unknown> {
215
+ const backend = getActiveCloudBackend();
216
+ return callCloudProxy<unknown>({
217
+ backend,
218
+ method: "GET",
219
+ path: "/api/v1/settings/agent-schema",
220
+ });
221
+ }
222
+
223
+ export async function fetchCloudConversationSettingsSchema(): Promise<unknown> {
224
+ const backend = getActiveCloudBackend();
225
+ return callCloudProxy<unknown>({
226
+ backend,
227
+ method: "GET",
228
+ path: "/api/v1/settings/conversation-schema",
229
+ });
230
+ }
src/api/cloud/skills-service.api.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SkillInfo } from "#/types/settings";
2
+ import { getActiveBackend } from "../backend-registry/active-store";
3
+ import type { Backend } from "../backend-registry/types";
4
+ import { callCloudProxy } from "./proxy";
5
+
6
+ interface CloudSkillsPage {
7
+ items: SkillInfo[];
8
+ next_page_id: string | null;
9
+ }
10
+
11
+ const PAGE_LIMIT = 100;
12
+
13
+ function getActiveCloudBackend(): Backend {
14
+ const active = getActiveBackend().backend;
15
+ if (active.kind !== "cloud") {
16
+ throw new Error("Cloud skills call requires a cloud backend.");
17
+ }
18
+ return active;
19
+ }
20
+
21
+ /**
22
+ * Fetch the full list of skills from the cloud backend. The cloud endpoint is
23
+ * paginated (page_id cursor); we walk all pages so the settings UI gets a
24
+ * complete list in one call. The cloud SkillInfo shape
25
+ * (name/type/source/triggers) matches the GUI's SkillInfo type, so items are
26
+ * passed through unchanged.
27
+ */
28
+ export async function fetchCloudSkills(): Promise<SkillInfo[]> {
29
+ const backend = getActiveCloudBackend();
30
+
31
+ const skills: SkillInfo[] = [];
32
+ let pageId: string | null = null;
33
+
34
+ do {
35
+ const query = new URLSearchParams({ limit: String(PAGE_LIMIT) });
36
+ if (pageId) query.set("page_id", pageId);
37
+
38
+ const page = await callCloudProxy<CloudSkillsPage>({
39
+ backend,
40
+ method: "GET",
41
+ path: `/api/v1/skills/search?${query.toString()}`,
42
+ });
43
+
44
+ skills.push(...(page.items ?? []));
45
+ pageId = page.next_page_id;
46
+ } while (pageId);
47
+
48
+ return skills;
49
+ }
50
+
51
+ interface CloudConversationSkill {
52
+ name: string;
53
+ type: SkillInfo["type"];
54
+ content: string;
55
+ triggers: string[];
56
+ }
57
+
58
+ interface CloudConversationSkillsResponse {
59
+ skills: CloudConversationSkill[];
60
+ }
61
+
62
+ /**
63
+ * Fetch the skills loaded into a running cloud conversation from the
64
+ * per-conversation route (the one the OpenHands web UI's own "Show Available
65
+ * Skills" modal uses). Unlike `/api/v1/skills/search`, which only scans the
66
+ * API host's built-in skills directory, this resolves the conversation's
67
+ * sandbox and asks its agent-server for the merged set: public catalog,
68
+ * user/org repos, project skills and auto-loaded marketplace plugins. The
69
+ * route reports no `source`, so it is `null`.
70
+ */
71
+ export async function fetchCloudConversationSkills(
72
+ conversationId: string,
73
+ ): Promise<SkillInfo[]> {
74
+ const backend = getActiveCloudBackend();
75
+
76
+ const data = await callCloudProxy<CloudConversationSkillsResponse>({
77
+ backend,
78
+ method: "GET",
79
+ path: `/api/v1/app-conversations/${conversationId}/skills`,
80
+ });
81
+
82
+ return (data?.skills ?? []).map((skill) => ({ ...skill, source: null }));
83
+ }
src/api/cloud/types.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Minimal slice of the OpenHands cloud organization shape that the GUI needs
3
+ * to render the backend selector. Full shape lives in the OpenHands repo;
4
+ * we keep only the fields actually read by this codebase.
5
+ */
6
+ export interface CloudOrganization {
7
+ id: string;
8
+ name: string;
9
+ is_personal?: boolean;
10
+ }
11
+
12
+ export interface CloudOrganizationsResponse {
13
+ items: CloudOrganization[];
14
+ current_org_id: string | null;
15
+ }
16
+
17
+ /**
18
+ * Subset of OpenHands' OrganizationMember that the GUI reads. Returned by
19
+ * `GET /api/organizations/{orgId}/me`. The relationship between the org and
20
+ * the current user is the source of truth for "is this a personal
21
+ * workspace?" — namely `org_id === user_id`.
22
+ */
23
+ export interface CloudOrganizationMember {
24
+ org_id: string;
25
+ user_id: string;
26
+ email?: string | null;
27
+ role?: string;
28
+ status?: string;
29
+ }
30
+
31
+ /**
32
+ * Response from `GET /api/keys/current`. Identifies the org the calling
33
+ * API key is scoped to. Legacy keys without an org binding cause the
34
+ * upstream to return HTTP 400 instead of producing this shape — the
35
+ * caller catches that case rather than expecting a partial response.
36
+ */
37
+ export interface CloudApiKeyMetadata {
38
+ id: string;
39
+ name: string;
40
+ org_id: string | null;
41
+ user_id: string;
42
+ auth_type: string;
43
+ }
44
+
45
+ /**
46
+ * Response from `GET /api/v1/users/git-info`. Identifies the currently
47
+ * authenticated user across the connected git providers.
48
+ */
49
+ export interface CloudGitUser {
50
+ id: string;
51
+ login: string;
52
+ avatar_url: string;
53
+ company: string | null;
54
+ name: string | null;
55
+ email: string | null;
56
+ }
src/api/config-service/config-service.api.ts ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LLMMetadataClient } from "@openhands/typescript-client/clients";
2
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
3
+ import { getActiveBackend } from "../backend-registry/active-store";
4
+ import { callCloudProxy } from "../cloud/proxy";
5
+ import type {
6
+ LLMModel,
7
+ LLMModelPage,
8
+ LLMProvider,
9
+ ProviderPage,
10
+ SearchModelsParams,
11
+ SearchProvidersParams,
12
+ } from "./config-service.types";
13
+
14
+ function filterByQuery<T extends { name: string }>(
15
+ items: T[],
16
+ query?: string,
17
+ ): T[] {
18
+ if (!query) {
19
+ return items;
20
+ }
21
+
22
+ const normalizedQuery = query.toLowerCase();
23
+ return items.filter((item) =>
24
+ item.name.toLowerCase().includes(normalizedQuery),
25
+ );
26
+ }
27
+
28
+ function filterByVerified<T extends { verified: boolean }>(
29
+ items: T[],
30
+ verified?: boolean,
31
+ ): T[] {
32
+ if (verified === undefined) {
33
+ return items;
34
+ }
35
+
36
+ return items.filter((item) => item.verified === verified);
37
+ }
38
+
39
+ function limitItems<T>(items: T[], limit?: number): T[] {
40
+ if (!limit || limit <= 0) {
41
+ return items;
42
+ }
43
+
44
+ return items.slice(0, limit);
45
+ }
46
+
47
+ function buildCloudQueryString(
48
+ params: Record<string, string | number | boolean | undefined>,
49
+ ): string {
50
+ const qs = new URLSearchParams();
51
+ for (const [key, value] of Object.entries(params)) {
52
+ if (value !== undefined) qs.set(key, String(value));
53
+ }
54
+ const str = qs.toString();
55
+ return str ? `?${str}` : "";
56
+ }
57
+
58
+ class ConfigService {
59
+ /**
60
+ * @param verifiedByProvider - Pre-fetched verified-models map used by the
61
+ * local reconstruction path. Ignored for cloud backends, which call
62
+ * `/api/v1/config/models/search` directly (verified status is embedded in
63
+ * each returned item).
64
+ */
65
+ static async searchModels(
66
+ params: SearchModelsParams = {},
67
+ verifiedByProvider?: Record<string, string[]>,
68
+ ): Promise<LLMModelPage> {
69
+ const active = getActiveBackend();
70
+
71
+ if (active.backend.kind === "cloud") {
72
+ // Cloud exposes /api/v1/config/models/search which returns LLMModelPage directly.
73
+ // verifiedByProvider is not needed — the cloud API embeds verified status natively.
74
+ const qs = buildCloudQueryString({
75
+ page_id: params.page_id,
76
+ limit: params.limit,
77
+ query: params.query,
78
+ verified__eq: params.verified__eq,
79
+ provider__eq: params.provider__eq,
80
+ });
81
+ return callCloudProxy<LLMModelPage>({
82
+ backend: active.backend,
83
+ method: "GET",
84
+ path: `/api/v1/config/models/search${qs}`,
85
+ });
86
+ }
87
+
88
+ const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
89
+ const verifiedFetch =
90
+ verifiedByProvider !== undefined
91
+ ? Promise.resolve(verifiedByProvider)
92
+ : llmClient.getVerifiedModels();
93
+ const [models, verifiedMap] = await Promise.all([
94
+ llmClient.getModels(),
95
+ verifiedFetch,
96
+ ]);
97
+
98
+ const provider = params.provider__eq ?? null;
99
+ const verifiedNames = new Set(
100
+ provider ? (verifiedMap?.[provider] ?? []) : [],
101
+ );
102
+ // The local agent-server has no model database, so it exposes no free /
103
+ // default metadata; both are always false here. Cloud backends embed the
104
+ // DB-driven flags natively on each item (handled above).
105
+ const verifiedItems: LLMModel[] = [...verifiedNames].map((name) => ({
106
+ provider,
107
+ name,
108
+ verified: true,
109
+ free: false,
110
+ default: false,
111
+ }));
112
+
113
+ const prefixedItems: LLMModel[] = provider
114
+ ? (models ?? [])
115
+ .filter((model) => model.startsWith(`${provider}/`))
116
+ .map((model) => model.slice(provider.length + 1))
117
+ .filter((name) => name.length > 0 && !verifiedNames.has(name))
118
+ .map((name) => ({
119
+ provider,
120
+ name,
121
+ verified: false,
122
+ free: false,
123
+ default: false,
124
+ }))
125
+ : [];
126
+
127
+ const items = limitItems(
128
+ filterByVerified(
129
+ filterByQuery([...verifiedItems, ...prefixedItems], params.query),
130
+ params.verified__eq,
131
+ ),
132
+ params.limit,
133
+ );
134
+
135
+ return { items, next_page_id: null };
136
+ }
137
+
138
+ /**
139
+ * @param verifiedByProvider - Pre-fetched verified-models map used by the
140
+ * local reconstruction path. Ignored for cloud backends, which call
141
+ * `/api/v1/config/providers/search` directly (verified status is embedded in
142
+ * each returned item).
143
+ */
144
+ static async searchProviders(
145
+ params: SearchProvidersParams = {},
146
+ verifiedByProvider?: Record<string, string[]>,
147
+ ): Promise<ProviderPage> {
148
+ const active = getActiveBackend();
149
+
150
+ if (active.backend.kind === "cloud") {
151
+ // Cloud exposes /api/v1/config/providers/search which returns ProviderPage directly.
152
+ // verifiedByProvider is not needed — the cloud API embeds verified status natively.
153
+ const qs = buildCloudQueryString({
154
+ page_id: params.page_id,
155
+ limit: params.limit,
156
+ query: params.query,
157
+ verified__eq: params.verified__eq,
158
+ });
159
+ return callCloudProxy<ProviderPage>({
160
+ backend: active.backend,
161
+ method: "GET",
162
+ path: `/api/v1/config/providers/search${qs}`,
163
+ });
164
+ }
165
+
166
+ const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
167
+ const verifiedFetch =
168
+ verifiedByProvider !== undefined
169
+ ? Promise.resolve(verifiedByProvider)
170
+ : llmClient.getVerifiedModels();
171
+ const [providers, verifiedMap] = await Promise.all([
172
+ llmClient.getProviders(),
173
+ verifiedFetch,
174
+ ]);
175
+
176
+ const verifiedProviders = new Set(Object.keys(verifiedMap ?? {}));
177
+ const names = new Set<string>([...verifiedProviders, ...(providers ?? [])]);
178
+ const providerItems: LLMProvider[] = [...names].map((name) => ({
179
+ name,
180
+ verified: verifiedProviders.has(name),
181
+ }));
182
+
183
+ const items = limitItems(
184
+ filterByVerified(
185
+ filterByQuery(providerItems, params.query),
186
+ params.verified__eq,
187
+ ),
188
+ params.limit,
189
+ );
190
+
191
+ return { items, next_page_id: null };
192
+ }
193
+ }
194
+
195
+ export default ConfigService;
src/api/config-service/config-service.types.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** V1 Config API types for models and providers */
2
+
3
+ export interface LLMModel {
4
+ provider: string | null;
5
+ name: string;
6
+ verified: boolean;
7
+ /**
8
+ * Whether the model is free to use on the OpenHands provider. Mirrors
9
+ * `verified`: it is populated by the backend model list (DB-driven on
10
+ * cloud) and defaults to `false` where no free metadata exists (e.g. the
11
+ * local agent-server reconstruction path, which has no model database).
12
+ */
13
+ free: boolean;
14
+ /**
15
+ * Whether this is the provider's default model. Mirrors `free`: DB-driven on
16
+ * cloud, `false` where no default metadata exists. Used to preselect the
17
+ * model on onboarding and when creating a new model for the provider.
18
+ */
19
+ default: boolean;
20
+ }
21
+
22
+ export interface LLMModelPage {
23
+ items: LLMModel[];
24
+ next_page_id: string | null;
25
+ }
26
+
27
+ export interface SearchModelsParams {
28
+ page_id?: string;
29
+ limit?: number;
30
+ query?: string;
31
+ verified__eq?: boolean;
32
+ provider__eq?: string;
33
+ }
34
+
35
+ export interface LLMProvider {
36
+ name: string;
37
+ verified: boolean;
38
+ }
39
+
40
+ export interface ProviderPage {
41
+ items: LLMProvider[];
42
+ next_page_id: string | null;
43
+ }
44
+
45
+ export interface SearchProvidersParams {
46
+ page_id?: string;
47
+ limit?: number;
48
+ query?: string;
49
+ verified__eq?: boolean;
50
+ }
src/api/conversation-service/agent-server-conversation-service.api.ts ADDED
@@ -0,0 +1,1145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ConversationSortOrder,
3
+ type ForkConversationRequest,
4
+ type LLMConfig,
5
+ type VSCodeStatusResponse,
6
+ } from "@openhands/typescript-client";
7
+ import {
8
+ ConversationClient,
9
+ FileClient,
10
+ ProfilesClient,
11
+ VSCodeClient,
12
+ } from "@openhands/typescript-client/clients";
13
+ import { v4 as uuidv4 } from "uuid";
14
+ import { AgentKind, Provider } from "#/types/settings";
15
+ import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api";
16
+ import { buildHttpBaseUrl } from "#/utils/websocket-url";
17
+ import {
18
+ buildConversationWorkingDirForBackend,
19
+ getAgentServerWorkingDir,
20
+ getWorkspaceRootForBackend,
21
+ } from "../agent-server-config";
22
+ import { resolveAbsoluteAgentServerPath } from "../agent-server-home";
23
+ import {
24
+ getActiveBackend,
25
+ getEffectiveLocalBackend,
26
+ } from "../backend-registry/active-store";
27
+ import { callCloudProxy } from "../cloud/proxy";
28
+ import ProfilesService from "../profiles-service/profiles-service.api";
29
+ import {
30
+ batchGetCloudConversations,
31
+ createCloudAppConversation,
32
+ deleteCloudConversation,
33
+ downloadCloudConversation,
34
+ getCloudAppConversationStartTask,
35
+ readCloudConversationFile,
36
+ searchCloudConversations,
37
+ updateCloudConversationPublicFlag,
38
+ updateCloudConversationTitle,
39
+ } from "../cloud/conversation-service.api";
40
+ import {
41
+ DirectConversationInfo,
42
+ assertSubscriptionAuthReady,
43
+ buildStartConversationRequestWithEncryptedSettings,
44
+ buildStartPlanningConversationRequestWithEncryptedSettings,
45
+ emptyHooksResponse,
46
+ getDefaultConversationTitle,
47
+ toAppConversation,
48
+ toConversationPage,
49
+ } from "../agent-server-adapter";
50
+ import { GetVSCodeUrlResponse } from "../open-hands.types";
51
+ import {
52
+ getAgentServerClientOptions,
53
+ NoBackendAvailableError,
54
+ } from "../agent-server-client-options";
55
+ import SettingsService from "../settings-service/settings-service.api";
56
+ import { getTelemetryDistinctId } from "../../services/telemetry";
57
+ import {
58
+ ConversationMetadata,
59
+ getStoredConversationMetadata,
60
+ mergeStoredConversationMetadata,
61
+ removeStoredConversationMetadata,
62
+ setStoredConversationMetadata,
63
+ type WorkspaceMode,
64
+ } from "../conversation-metadata-store";
65
+ import { resolveTitleLlmProfile } from "#/utils/title-llm-profile";
66
+ import { isPlannerConversationOf } from "#/utils/plan-file";
67
+ import type {
68
+ GetHooksResponse,
69
+ PluginSpec,
70
+ AppConversation,
71
+ AppConversationPage,
72
+ AppConversationStartRequest,
73
+ AppConversationStartTask,
74
+ MetricsSnapshot,
75
+ RuntimeConversationInfo,
76
+ RuntimeConversationStats,
77
+ SendMessageRequest,
78
+ SendMessageResponse,
79
+ } from "./agent-server-conversation-service.types";
80
+
81
+ const DEFAULT_CONVERSATION_TIMESTAMP = "1970-01-01T00:00:00.000Z";
82
+ // Creating the first conversation right after a cold agent-server boot (fresh
83
+ // machine or the packaged desktop app, where uvx may still be warming caches)
84
+ // can exceed the client's 60s default timeout.
85
+ const CREATE_CONVERSATION_TIMEOUT_MS = 5 * 60 * 1000;
86
+ const INVALID_CONVERSATION_RESPONSE_MESSAGE =
87
+ "Unable to load conversations because the selected agent server returned " +
88
+ "data this UI does not understand. Check the backend URL/session key and " +
89
+ "update the agent server if needed.";
90
+ function invalidConversationResponse(): Error {
91
+ return new Error(INVALID_CONVERSATION_RESPONSE_MESSAGE);
92
+ }
93
+
94
+ function isRecord(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === "object" && value !== null && !Array.isArray(value);
96
+ }
97
+
98
+ function numberOrNull(value: unknown): number | null {
99
+ return typeof value === "number" ? value : null;
100
+ }
101
+
102
+ function numberOrZero(value: unknown): number {
103
+ return typeof value === "number" ? value : 0;
104
+ }
105
+
106
+ function stringOrNull(value: unknown): string | null {
107
+ return typeof value === "string" ? value : null;
108
+ }
109
+
110
+ function readTimestamp(
111
+ item: Record<string, unknown>,
112
+ snakeKey: "created_at" | "updated_at",
113
+ camelKey: "createdAt" | "updatedAt",
114
+ ): string {
115
+ const value = item[snakeKey] ?? item[camelKey];
116
+ return typeof value === "string" && value.trim()
117
+ ? value
118
+ : DEFAULT_CONVERSATION_TIMESTAMP;
119
+ }
120
+
121
+ function normalizeTokenUsage(
122
+ value: unknown,
123
+ ): NonNullable<MetricsSnapshot["accumulated_token_usage"]> | null {
124
+ if (!isRecord(value)) return null;
125
+
126
+ return {
127
+ prompt_tokens: numberOrZero(value.prompt_tokens),
128
+ completion_tokens: numberOrZero(value.completion_tokens),
129
+ cache_read_tokens: numberOrZero(value.cache_read_tokens),
130
+ cache_write_tokens: numberOrZero(value.cache_write_tokens),
131
+ context_window: numberOrZero(value.context_window),
132
+ per_turn_token: numberOrZero(value.per_turn_token),
133
+ };
134
+ }
135
+
136
+ function normalizeMetrics(value: unknown): MetricsSnapshot | null {
137
+ if (!isRecord(value)) return null;
138
+
139
+ return {
140
+ accumulated_cost: numberOrNull(value.accumulated_cost),
141
+ max_budget_per_task: numberOrNull(value.max_budget_per_task),
142
+ accumulated_token_usage: normalizeTokenUsage(value.accumulated_token_usage),
143
+ };
144
+ }
145
+
146
+ // Shallow check only (matches the trust level `getRuntimeConversation` used
147
+ // before this field was threaded through `DirectConversationInfo`): the
148
+ // per-usage-id entries are consumed via `combineUsageMetrics`, which already
149
+ // tolerates missing/malformed fields, so there's no need to validate them here.
150
+ function normalizeStats(value: unknown): RuntimeConversationStats | null {
151
+ return isRecord(value)
152
+ ? (value as unknown as RuntimeConversationStats)
153
+ : null;
154
+ }
155
+
156
+ function normalizeAgent(value: unknown): DirectConversationInfo["agent"] {
157
+ if (!isRecord(value)) return null;
158
+ const llm = isRecord(value.llm)
159
+ ? { model: stringOrNull(value.llm.model) }
160
+ : null;
161
+ // ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``);
162
+ // ``toAppConversation`` reads it to derive ``agent_kind``. ``acp_server`` is
163
+ // the ACP provider identity (``ACPAgent.acp_server``, SDK #3692) — required so
164
+ // the adapter can source the chip + in-conversation model list from the agent
165
+ // when the ``acpserver`` tag is absent (a profile launch doesn't stamp it,
166
+ // #1571). ``acp_model`` is the Canvas-configured model — preserved so the chip
167
+ // can fall back to it when the SDK runtime model fields aren't populated.
168
+ // Preserving these makes the wire path agree with the unit-test path that
169
+ // builds ``DirectConversationInfo`` directly
170
+ // (e.g. ``__tests__/api/agent-server-adapter.test.ts``).
171
+ return {
172
+ kind: stringOrNull(value.kind),
173
+ acp_server: stringOrNull(value.acp_server),
174
+ acp_model: stringOrNull(value.acp_model),
175
+ llm,
176
+ };
177
+ }
178
+
179
+ function normalizeWorkspace(
180
+ value: unknown,
181
+ ): DirectConversationInfo["workspace"] {
182
+ if (!isRecord(value)) return null;
183
+ return { working_dir: stringOrNull(value.working_dir) };
184
+ }
185
+
186
+ /**
187
+ * Accept the agent-server's ``tags: Record[str, str]`` payload defensively:
188
+ * the wire shape is guaranteed by the server-side validator (keys
189
+ * ``^[a-z0-9]+$``, string values), but a non-conforming response (older
190
+ * server, raw API write, future schema drift) must never crash the parser
191
+ * — Canvas only consumes ``acpserver`` and falls back to a generic chip
192
+ * for anything it doesn't recognize. Drop entries whose value isn't a
193
+ * plain string; return ``null`` when the wire field is absent or not an
194
+ * object so consumers can use ``info.tags?.[KEY] ?? null`` uniformly.
195
+ */
196
+ function normalizeTags(value: unknown): Record<string, string> | null {
197
+ if (!isRecord(value)) return null;
198
+ const tags: Record<string, string> = {};
199
+ for (const [key, entry] of Object.entries(value)) {
200
+ if (typeof entry === "string") {
201
+ tags[key] = entry;
202
+ }
203
+ }
204
+ return tags;
205
+ }
206
+
207
+ /**
208
+ * ``ConversationInfo.sub_conversation_ids`` — the agent-server derives it from
209
+ * its own catalog of conversations that name this one as parent (SDK #4188),
210
+ * which is what lets Canvas find a conversation's local planner without any
211
+ * browser-local state. Absent on agent-servers older than 1.37.1; parsed
212
+ * defensively so a non-conforming payload degrades to "no children" instead of
213
+ * crashing the list.
214
+ */
215
+ function normalizeSubConversationIds(value: unknown): string[] | null {
216
+ if (!Array.isArray(value)) return null;
217
+ return value.filter((entry): entry is string => typeof entry === "string");
218
+ }
219
+
220
+ function normalizeLaunchedAgentProfile(
221
+ value: unknown,
222
+ ): DirectConversationInfo["launched_agent_profile"] {
223
+ if (!isRecord(value)) return null;
224
+ const { agent_profile_id: agentProfileId, revision } = value;
225
+ if (typeof agentProfileId !== "string" || typeof revision !== "number") {
226
+ return null;
227
+ }
228
+ return { agent_profile_id: agentProfileId, revision };
229
+ }
230
+
231
+ function normalizeAbsolutePath(path: string): string | null {
232
+ if (!path.startsWith("/")) return null;
233
+
234
+ const segments: string[] = [];
235
+ for (const segment of path.split("/")) {
236
+ if (segment && segment !== ".") {
237
+ if (segment === "..") {
238
+ if (!segments.length) return null;
239
+ segments.pop();
240
+ } else {
241
+ segments.push(segment);
242
+ }
243
+ }
244
+ }
245
+
246
+ return `/${segments.join("/")}`;
247
+ }
248
+
249
+ function requirePathInsideDirectory(path: string, directory: string): string {
250
+ const normalizedPath = normalizeAbsolutePath(path);
251
+ const normalizedDirectory = normalizeAbsolutePath(directory);
252
+
253
+ if (
254
+ !normalizedPath ||
255
+ !normalizedDirectory ||
256
+ (normalizedPath !== normalizedDirectory &&
257
+ !normalizedPath.startsWith(`${normalizedDirectory}/`))
258
+ ) {
259
+ throw new Error("Conversation file path must stay inside the workspace");
260
+ }
261
+
262
+ return normalizedPath;
263
+ }
264
+
265
+ function requireDirectConversationInfo(item: unknown): DirectConversationInfo {
266
+ if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) {
267
+ throw invalidConversationResponse();
268
+ }
269
+
270
+ return {
271
+ id: item.id.trim(),
272
+ title: stringOrNull(item.title),
273
+ created_at: readTimestamp(item, "created_at", "createdAt"),
274
+ updated_at: readTimestamp(item, "updated_at", "updatedAt"),
275
+ execution_status: stringOrNull(item.execution_status),
276
+ sandbox_status: stringOrNull(item.sandbox_status),
277
+ metrics: normalizeMetrics(item.metrics),
278
+ stats: normalizeStats(item.stats),
279
+ agent: normalizeAgent(item.agent),
280
+ workspace: normalizeWorkspace(item.workspace),
281
+ tags: normalizeTags(item.tags),
282
+ launched_agent_profile: normalizeLaunchedAgentProfile(
283
+ item.launched_agent_profile,
284
+ ),
285
+ sub_conversation_ids: normalizeSubConversationIds(
286
+ item.sub_conversation_ids,
287
+ ),
288
+ // SDK-runtime ACP model fields (populated when the agent-server supports
289
+ // ``ConversationInfo.current_model_*``). Consumed by the conversation
290
+ // adapter to drive the per-card chip's model text. Older agent-servers
291
+ // omit these — adapter handles ``undefined`` / ``null`` gracefully.
292
+ current_model_id: stringOrNull(item.current_model_id),
293
+ current_model_name: stringOrNull(item.current_model_name),
294
+ };
295
+ }
296
+
297
+ function requireDirectConversationItems(
298
+ items: unknown,
299
+ ): DirectConversationInfo[] {
300
+ if (!Array.isArray(items)) {
301
+ throw invalidConversationResponse();
302
+ }
303
+ return items.map(requireDirectConversationInfo);
304
+ }
305
+
306
+ function requireConversationSearchPage(page: unknown): {
307
+ items: DirectConversationInfo[];
308
+ next_page_id: string | null;
309
+ } {
310
+ if (Array.isArray(page)) {
311
+ return {
312
+ items: requireDirectConversationItems(page),
313
+ next_page_id: null,
314
+ };
315
+ }
316
+
317
+ if (!isRecord(page)) {
318
+ throw invalidConversationResponse();
319
+ }
320
+
321
+ return {
322
+ items: requireDirectConversationItems(page.items),
323
+ next_page_id:
324
+ typeof page.next_page_id === "string" ? page.next_page_id : null,
325
+ };
326
+ }
327
+
328
+ const RUNTIME_STATUSES = new Set<string>([
329
+ "idle",
330
+ "running",
331
+ "paused",
332
+ "waiting_for_confirmation",
333
+ "finished",
334
+ "error",
335
+ "stuck",
336
+ ]);
337
+
338
+ function toRuntimeStatus(
339
+ status: DirectConversationInfo["execution_status"],
340
+ ): RuntimeConversationInfo["status"] {
341
+ const nextStatus = status ?? "idle";
342
+ return (
343
+ RUNTIME_STATUSES.has(nextStatus) ? nextStatus : "idle"
344
+ ) as RuntimeConversationInfo["status"];
345
+ }
346
+
347
+ function requireAppConversation(
348
+ conversation: AppConversation | null | undefined,
349
+ conversationId: string,
350
+ ): AppConversation {
351
+ if (!conversation) {
352
+ throw new Error(`Conversation ${conversationId} was not found`);
353
+ }
354
+ return conversation;
355
+ }
356
+
357
+ /**
358
+ * Options for {@link AgentServerConversationService.createConversation}.
359
+ */
360
+ export interface CreateConversationOptions {
361
+ initialUserMsg?: string;
362
+ conversationInstructions?: string;
363
+ plugins?: PluginSpec[];
364
+ metadata?: ConversationMetadata | null;
365
+ workingDirOverride?: string;
366
+ workspaceMode?: WorkspaceMode;
367
+ parentConversationId?: string;
368
+ agentType?: "default" | "plan";
369
+ sandboxId?: string;
370
+ // Launch from a saved AgentProfile (resolved server-side) instead of the
371
+ // current encrypted agent_settings (#3727). Supported on both local and the
372
+ // cloud app-server (OpenHands #15060): local threads it through the
373
+ // encrypted-settings builder; cloud sends it as a flat request field.
374
+ agentProfileId?: string;
375
+ agentProfileKind?: AgentKind;
376
+ }
377
+
378
+ class AgentServerConversationService {
379
+ static async sendMessage(
380
+ conversationId: string,
381
+ message: SendMessageRequest,
382
+ runtime?: ConversationRuntimeContext | null,
383
+ ): Promise<SendMessageResponse> {
384
+ const active = getActiveBackend().backend;
385
+ let conversationUrl = runtime?.conversationUrl ?? null;
386
+ let sessionApiKey = runtime?.sessionApiKey ?? null;
387
+
388
+ if (active.kind === "cloud") {
389
+ if (!conversationUrl || !sessionApiKey) {
390
+ const [conversation] = await batchGetCloudConversations([
391
+ conversationId,
392
+ ]);
393
+ conversationUrl = conversation?.conversation_url?.trim() ?? null;
394
+ sessionApiKey = conversation?.session_api_key?.trim() ?? null;
395
+ }
396
+
397
+ if (!conversationUrl || !sessionApiKey) {
398
+ throw new Error(
399
+ "Conversation sandbox is still starting. Wait for it to finish, then try again.",
400
+ );
401
+ }
402
+
403
+ await callCloudProxy({
404
+ backend: active,
405
+ method: "POST",
406
+ hostOverride: buildHttpBaseUrl(conversationUrl),
407
+ path: `/api/conversations/${conversationId}/events`,
408
+ body: { ...message, run: true },
409
+ authMode: "session-api-key",
410
+ sessionApiKey,
411
+ });
412
+
413
+ return message;
414
+ }
415
+
416
+ await new ConversationClient(
417
+ getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
418
+ ).sendEvent(conversationId, message, {
419
+ run: true,
420
+ });
421
+
422
+ return message;
423
+ }
424
+
425
+ static async createConversation(
426
+ options: CreateConversationOptions = {},
427
+ ): Promise<AppConversationStartTask> {
428
+ const {
429
+ initialUserMsg,
430
+ conversationInstructions,
431
+ plugins,
432
+ metadata,
433
+ workingDirOverride,
434
+ workspaceMode,
435
+ parentConversationId,
436
+ agentType,
437
+ sandboxId,
438
+ agentProfileId,
439
+ agentProfileKind,
440
+ } = options;
441
+
442
+ if (getActiveBackend().backend.kind === "cloud") {
443
+ // Cloud path mirrors OpenHands' frontend: build a flat
444
+ // AppConversationStartRequest, POST /api/v1/app-conversations
445
+ // (returns a WORKING task), and let the conversation route's
446
+ // useTaskPolling drive it to READY. NO encrypted-settings
447
+ // round-trip — the cloud backend holds secrets server-side.
448
+ // When launching from a profile, send `agent_profile_id`; the backend
449
+ // resolves it to agent_settings server-side.
450
+ const request: AppConversationStartRequest = {
451
+ initial_message: initialUserMsg
452
+ ? {
453
+ role: "user",
454
+ content: [{ type: "text", text: initialUserMsg }],
455
+ }
456
+ : null,
457
+ title: conversationInstructions ?? null,
458
+ selected_repository: metadata?.selected_repository ?? null,
459
+ selected_branch: metadata?.selected_branch ?? null,
460
+ git_provider: metadata?.git_provider ?? null,
461
+ plugins: plugins ?? null,
462
+ parent_conversation_id: parentConversationId ?? null,
463
+ agent_type: agentType,
464
+ sandbox_id: sandboxId ?? null,
465
+ agent_profile_id: agentProfileId ?? null,
466
+ trigger: "gui",
467
+ };
468
+ return createCloudAppConversation(request);
469
+ }
470
+
471
+ const [settings, profiles] = await Promise.all([
472
+ SettingsService.getSettings(),
473
+ ProfilesService.listProfiles().catch(() => undefined),
474
+ ]);
475
+ const titleLlmProfile = resolveTitleLlmProfile(
476
+ settings.title_llm_profile,
477
+ profiles,
478
+ );
479
+ const conversationId = uuidv4();
480
+ // @spec WUP-001 — Send an absolute working_dir to the agent-server.
481
+ // The default is `workspace/project/<hex>` (relative); without
482
+ // resolving it here, `/api/file/upload` later prepends `/` and writes
483
+ // to `/workspace/...` (read-only on macOS and fresh containers). When
484
+ // the user picks an explicit workspace, `workingDirOverride` is
485
+ // already absolute (it comes from `search_subdirs`).
486
+ //
487
+ // Pick the base working dir per-backend:
488
+ // 1. explicit user workspace pick → use it as-is;
489
+ // 2. no pick, backend that served this frontend → the baked default
490
+ // (honors a launcher-baked absolute `VITE_WORKING_DIR`);
491
+ // 3. no pick, any other backend → the backend-relative default.
492
+ // A baked absolute dir is a path on the host that served this frontend,
493
+ // so it is only valid on that backend. Using it for a different backend
494
+ // (e.g. a remote sandbox) makes the agent-server mkdir an unwritable path
495
+ // and the conversation fails at the first prompt (e.g. `Permission
496
+ // denied: '/Users'`). The relative default is anchored per-backend by
497
+ // `resolveAbsoluteAgentServerPath()` via `/api/file/home`. The gate keys
498
+ // on the active backend's host (not its id): the seeded `default-local`
499
+ // entry is mutable, so a user can edit it to point at a remote host while
500
+ // its id stays `default-local`.
501
+ const backendHost = getActiveBackend().backend.host;
502
+ const baseWorkingDir =
503
+ workingDirOverride ??
504
+ buildConversationWorkingDirForBackend(conversationId, backendHost);
505
+ const workingDir = await resolveAbsoluteAgentServerPath(baseWorkingDir);
506
+ // The agent-server checks `<project_dir>/.openhands/hooks.json` literally,
507
+ // so hooks need the workspace root: the per-conversation subdir below it is
508
+ // created only after this request (#16907). An explicit pick is the root.
509
+ const hooksProjectDir = workingDirOverride
510
+ ? workingDir
511
+ : await resolveAbsoluteAgentServerPath(
512
+ getWorkspaceRootForBackend(backendHost),
513
+ );
514
+ const resolvedWorkspaceMode =
515
+ workspaceMode ?? (workingDirOverride ? "local_repo" : "new_worktree");
516
+
517
+ // Use encrypted settings to avoid exposing secrets in the browser
518
+ const payload = await buildStartConversationRequestWithEncryptedSettings({
519
+ settings,
520
+ query: initialUserMsg,
521
+ conversationInstructions,
522
+ plugins,
523
+ conversationId,
524
+ // The agent-server rejects a parent in a different workspace, so callers
525
+ // launching a child must pass the parent's own `working_dir` as
526
+ // `workingDirOverride` (see `resolveConversationWorkingDir`). Servers
527
+ // older than 1.37.1 ignore the field and create an unlinked conversation.
528
+ parentConversationId,
529
+ workingDir,
530
+ hooksProjectDir,
531
+ worktree: resolvedWorkspaceMode === "new_worktree",
532
+ agentProfileId,
533
+ agentProfileKind,
534
+ titleLlmProfile,
535
+ });
536
+
537
+ const telemetryDistinctId = await getTelemetryDistinctId();
538
+ const data = await new ConversationClient(
539
+ getAgentServerClientOptions({ timeout: CREATE_CONVERSATION_TIMEOUT_MS }),
540
+ ).createConversation<DirectConversationInfo>({
541
+ ...payload,
542
+ ...(telemetryDistinctId ? { user_id: telemetryDistinctId } : {}),
543
+ });
544
+ const localBackend = getEffectiveLocalBackend();
545
+ if (!localBackend) throw new NoBackendAvailableError();
546
+
547
+ if (metadata?.selected_repository || workingDirOverride) {
548
+ // The agent-server runtime has no concept of selected repo/branch/
549
+ // workspace, so persist the home-page selection client-side.
550
+ // `toAppConversation` reads the repo/branch fields back to hydrate
551
+ // the chat-page badges; `useHasAttachedSource` reads
552
+ // `selected_workspace` to default the Files tab to Diff mode when
553
+ // the user explicitly attached a local workspace.
554
+ setStoredConversationMetadata(data.id, {
555
+ selected_repository: metadata?.selected_repository ?? null,
556
+ selected_branch: metadata?.selected_branch ?? null,
557
+ git_provider: metadata?.git_provider ?? null,
558
+ selected_workspace: workingDirOverride ?? null,
559
+ workspace_mode: resolvedWorkspaceMode,
560
+ });
561
+ }
562
+
563
+ return {
564
+ id: data.id,
565
+ created_by_user_id: null,
566
+ status: "READY",
567
+ detail: null,
568
+ app_conversation_id: data.id,
569
+ agent_server_url: localBackend.host,
570
+ request: {
571
+ initial_message: payload.initial_message as
572
+ | AppConversationStartRequest["initial_message"]
573
+ | undefined,
574
+ plugins: plugins ?? null,
575
+ },
576
+ created_at: data.created_at,
577
+ updated_at: data.updated_at,
578
+ };
579
+ }
580
+
581
+ static async createLocalPlanningConversation(
582
+ parentConversationId: string,
583
+ initialMessage?: string,
584
+ ): Promise<AppConversation> {
585
+ if (getActiveBackend().backend.kind === "cloud") {
586
+ throw new Error("Local planning conversations require a local backend.");
587
+ }
588
+
589
+ const [parent] = await this.batchGetAppConversations([
590
+ parentConversationId,
591
+ ]);
592
+ const workingDir =
593
+ parent?.workspace?.working_dir ?? getAgentServerWorkingDir();
594
+
595
+ const payload =
596
+ await buildStartPlanningConversationRequestWithEncryptedSettings({
597
+ workingDir,
598
+ parentConversationId,
599
+ // Pin the planner to the parent's own current model. Only meaningful
600
+ // for "openhands"-kind parents: an ACP parent's active_profile is a
601
+ // stale launch-time snapshot (/model is a no-op for ACP), not a live
602
+ // value, so treating it as authoritative would pin the planner to
603
+ // the wrong model instead of falling through to global settings.
604
+ parentActiveProfileName:
605
+ parent?.agent_kind === "openhands"
606
+ ? (parent?.active_profile ?? null)
607
+ : null,
608
+ // Fallback when active_profile can't be resolved (e.g. an ACP parent).
609
+ parentAgentProfileId:
610
+ parent?.launched_agent_profile?.agent_profile_id ?? null,
611
+ initialMessage,
612
+ });
613
+
614
+ const data = await new ConversationClient(
615
+ getAgentServerClientOptions(),
616
+ ).createConversation<DirectConversationInfo>(payload);
617
+
618
+ // Client-side fallback only: agent-servers >= 1.37.1 persist the link via
619
+ // `parent_conversation_id` and hand it back on the parent's
620
+ // `sub_conversation_ids`, which is the source of truth. This hint covers
621
+ // older backends that ignore the field.
622
+ mergeStoredConversationMetadata(parentConversationId, {
623
+ local_planning_conversation_id: data.id,
624
+ });
625
+
626
+ return toAppConversation(data);
627
+ }
628
+
629
+ /**
630
+ * Ids of the conversations owned by `parentConversationId` on a local
631
+ * backend — today that means its planner helper, the only child Canvas
632
+ * creates locally.
633
+ *
634
+ * `sub_conversation_ids` is the generic server-derived child list, so each
635
+ * child is kept only if it's tagged `plannerparent` for this parent —
636
+ * otherwise deleting the parent would also delete an unrelated non-planner
637
+ * child (e.g. a delegated sub-agent). The stored metadata hint is merged in
638
+ * for agent-servers older than 1.37.1, which report no children at all.
639
+ */
640
+ static async getLocalPlanningConversationIds(
641
+ parentConversationId: string,
642
+ ): Promise<string[]> {
643
+ if (getActiveBackend().backend.kind === "cloud") return [];
644
+
645
+ const ids = new Set<string>();
646
+
647
+ try {
648
+ const [parent] = await this.batchGetAppConversations([
649
+ parentConversationId,
650
+ ]);
651
+ const childIds = parent?.sub_conversation_ids ?? [];
652
+ if (childIds.length > 0) {
653
+ const children = await this.batchGetAppConversations(childIds);
654
+ for (const child of children) {
655
+ if (child && isPlannerConversationOf(child, parentConversationId)) {
656
+ ids.add(child.id);
657
+ }
658
+ }
659
+ }
660
+ } catch (error) {
661
+ // The stored hint below still covers the common case, and callers
662
+ // (delete) must not be blocked by a failed lookup.
663
+ console.warn(
664
+ `Failed to read sub-conversations of ${parentConversationId}`,
665
+ error,
666
+ );
667
+ }
668
+
669
+ const stored =
670
+ getStoredConversationMetadata(
671
+ parentConversationId,
672
+ )?.local_planning_conversation_id;
673
+ if (stored) ids.add(stored);
674
+
675
+ return [...ids];
676
+ }
677
+
678
+ static async getStartTask(
679
+ taskId: string,
680
+ ): Promise<AppConversationStartTask | null> {
681
+ if (getActiveBackend().backend.kind === "cloud") {
682
+ return getCloudAppConversationStartTask(taskId);
683
+ }
684
+ // Local agent-server creates conversations synchronously — every
685
+ // local "task" is already READY when createConversation returns, so
686
+ // there's nothing to poll for.
687
+ return null;
688
+ }
689
+
690
+ static async getVSCodeUrl(
691
+ conversationId: string,
692
+ conversationUrl: string | null | undefined,
693
+ sessionApiKey?: string | null,
694
+ ): Promise<GetVSCodeUrlResponse> {
695
+ // Local-only path. Cloud conversations read the VSCode URL straight
696
+ // from the cloud-computed `sandbox.exposed_urls` (see
697
+ // `useUnifiedVSCodeUrl` + `useCloudSandbox`); the runtime's own
698
+ // `/api/vscode/url` only knows its internal `localhost:8001`, which
699
+ // the user's browser can't reach.
700
+ const workspaceDir =
701
+ await this.resolveConversationWorkingDir(conversationId);
702
+ // Local mode: the typescript-client targets the local agent-server
703
+ // directly via the conversationUrl override.
704
+ const vscodeUrl = await new VSCodeClient(
705
+ getAgentServerClientOptions({
706
+ conversationUrl,
707
+ sessionApiKey,
708
+ }),
709
+ ).getUrl({
710
+ baseUrl:
711
+ typeof window !== "undefined" ? window.location.origin : undefined,
712
+ workspaceDir,
713
+ });
714
+
715
+ return { vscode_url: vscodeUrl };
716
+ }
717
+
718
+ /**
719
+ * Read the editor's capability state from the agent-server.
720
+ *
721
+ * `/api/vscode/status` answers 200 with `enabled: false` when the
722
+ * deployment set `enable_vscode: false`, which distinguishes "this
723
+ * deployment offers no editor" from a transport, auth, or server
724
+ * failure — `/api/vscode/url` answers 503 for the former and so
725
+ * cannot be told apart from the latter.
726
+ */
727
+ static async getVSCodeStatus(
728
+ conversationUrl: string | null | undefined,
729
+ sessionApiKey?: string | null,
730
+ ): Promise<VSCodeStatusResponse> {
731
+ return new VSCodeClient(
732
+ getAgentServerClientOptions({
733
+ conversationUrl,
734
+ sessionApiKey,
735
+ }),
736
+ ).getStatus();
737
+ }
738
+
739
+ static async resolveConversationWorkingDir(
740
+ conversationId: string,
741
+ ): Promise<string> {
742
+ const [conversation] = await this.batchGetAppConversations([
743
+ conversationId,
744
+ ]);
745
+ return conversation?.workspace?.working_dir ?? getAgentServerWorkingDir();
746
+ }
747
+
748
+ static async batchGetAppConversations(
749
+ ids: string[],
750
+ ): Promise<(AppConversation | null)[]> {
751
+ if (ids.length === 0) return [];
752
+
753
+ if (getActiveBackend().backend.kind === "cloud") {
754
+ return batchGetCloudConversations(ids);
755
+ }
756
+
757
+ const data = await new ConversationClient(
758
+ getAgentServerClientOptions(),
759
+ ).getConversations<DirectConversationInfo>(ids);
760
+
761
+ return requireDirectConversationItems(data).map((item) =>
762
+ toAppConversation(item),
763
+ );
764
+ }
765
+
766
+ static async updateConversationPublicFlag(
767
+ conversationId: string,
768
+ isPublic: boolean,
769
+ ): Promise<AppConversation> {
770
+ if (getActiveBackend().backend.kind !== "cloud") {
771
+ throw new Error("Public sharing requires a cloud backend.");
772
+ }
773
+ return updateCloudConversationPublicFlag(conversationId, isPublic);
774
+ }
775
+
776
+ static async updateConversationRepository(
777
+ conversationId: string,
778
+ repository: string | null,
779
+ branch?: string | null,
780
+ gitProvider?: string | null,
781
+ ): Promise<AppConversation> {
782
+ if (repository) {
783
+ const existing = getStoredConversationMetadata(conversationId);
784
+ setStoredConversationMetadata(conversationId, {
785
+ ...(existing ?? {}),
786
+ selected_repository: repository,
787
+ selected_branch: branch ?? null,
788
+ git_provider: (gitProvider as Provider | null | undefined) ?? null,
789
+ });
790
+ } else {
791
+ removeStoredConversationMetadata(conversationId);
792
+ }
793
+ const [conversation] = await this.batchGetAppConversations([
794
+ conversationId,
795
+ ]);
796
+ return requireAppConversation(conversation, conversationId);
797
+ }
798
+
799
+ static async readConversationFile(
800
+ conversationId: string,
801
+ filePath?: string,
802
+ ): Promise<string> {
803
+ if (getActiveBackend().backend.kind === "cloud") {
804
+ // Cloud exposes a per-conversation file endpoint; the sandbox
805
+ // working dir is fixed (`/workspace/project`), so PLAN.md lives at
806
+ // a known absolute path. Mirrors OpenHands' readConversationFile.
807
+ const path = requirePathInsideDirectory(
808
+ filePath ?? "/workspace/project/.agents_tmp/PLAN.md",
809
+ "/workspace/project",
810
+ );
811
+ return readCloudConversationFile(conversationId, path);
812
+ }
813
+
814
+ const workingDir = await this.resolveConversationWorkingDir(conversationId);
815
+ const path = requirePathInsideDirectory(
816
+ filePath ?? `${workingDir}/.agents_tmp/PLAN.md`,
817
+ workingDir,
818
+ );
819
+ return new FileClient(getAgentServerClientOptions()).downloadTextFile(path);
820
+ }
821
+
822
+ static async downloadConversation(conversationId: string): Promise<Blob> {
823
+ if (getActiveBackend().backend.kind === "cloud") {
824
+ return downloadCloudConversation(conversationId);
825
+ }
826
+
827
+ return new FileClient(getAgentServerClientOptions()).downloadTrajectory(
828
+ conversationId,
829
+ );
830
+ }
831
+
832
+ static async getHooks(conversationId: string): Promise<GetHooksResponse> {
833
+ if (!conversationId) {
834
+ return emptyHooksResponse();
835
+ }
836
+ return emptyHooksResponse();
837
+ }
838
+
839
+ static async getRuntimeConversation(
840
+ conversationId: string,
841
+ conversationUrl: string | null | undefined,
842
+ sessionApiKey?: string | null,
843
+ ): Promise<RuntimeConversationInfo> {
844
+ // Fetch directly from the per-conversation runtime agent-server at conversationUrl.
845
+ const response = await new ConversationClient(
846
+ getAgentServerClientOptions({
847
+ conversationUrl,
848
+ sessionApiKey,
849
+ }),
850
+ ).getConversation<DirectConversationInfo>(conversationId);
851
+ const data = requireDirectConversationInfo(response);
852
+
853
+ return {
854
+ id: data.id,
855
+ title: data.title?.trim()
856
+ ? data.title
857
+ : getDefaultConversationTitle(data.id),
858
+ metrics: normalizeMetrics(data.metrics),
859
+ created_at: data.created_at,
860
+ updated_at: data.updated_at,
861
+ status: toRuntimeStatus(data.execution_status),
862
+ stats: data.stats ?? { usage_to_metrics: {} },
863
+ };
864
+ }
865
+
866
+ /**
867
+ * Force condensation ("compact") of the conversation history via
868
+ * `POST /api/conversations/{id}/condense`. Routed the same way as
869
+ * {@link sendMessage}: through the cloud proxy at the conversation's own
870
+ * runtime host for cloud backends, directly against that runtime otherwise.
871
+ */
872
+ static async condenseConversation(
873
+ conversationId: string,
874
+ conversationUrl: string | null | undefined,
875
+ sessionApiKey?: string | null,
876
+ ): Promise<void> {
877
+ const active = getActiveBackend().backend;
878
+
879
+ if (active.kind === "cloud" && conversationUrl) {
880
+ await callCloudProxy({
881
+ backend: active,
882
+ method: "POST",
883
+ hostOverride: buildHttpBaseUrl(conversationUrl),
884
+ path: `/api/conversations/${conversationId}/condense`,
885
+ authMode: "session-api-key",
886
+ sessionApiKey,
887
+ });
888
+ return;
889
+ }
890
+
891
+ await new ConversationClient(
892
+ getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
893
+ ).condenseConversation(conversationId);
894
+ }
895
+
896
+ static async searchConversations(
897
+ limit: number = 20,
898
+ pageId?: string,
899
+ ): Promise<AppConversationPage> {
900
+ if (getActiveBackend().backend.kind === "cloud") {
901
+ return searchCloudConversations(limit, pageId);
902
+ }
903
+
904
+ const data = await new ConversationClient(
905
+ getAgentServerClientOptions(),
906
+ ).searchConversations({
907
+ limit,
908
+ page_id: pageId,
909
+ sort_order: ConversationSortOrder.UPDATED_AT_DESC,
910
+ });
911
+
912
+ return toConversationPage(requireConversationSearchPage(data));
913
+ }
914
+
915
+ static async deleteConversation(conversationId: string): Promise<void> {
916
+ if (getActiveBackend().backend.kind === "cloud") {
917
+ await deleteCloudConversation(conversationId);
918
+ removeStoredConversationMetadata(conversationId);
919
+ return;
920
+ }
921
+
922
+ // The agent-server orphans children rather than cascading, and the local
923
+ // planner helper is hidden from the conversation list by its
924
+ // `plannerparent` tag — so without this it would survive its parent as an
925
+ // invisible, unreachable conversation (plus its events and state).
926
+ const planningConversationIds =
927
+ await this.getLocalPlanningConversationIds(conversationId);
928
+
929
+ const client = new ConversationClient(getAgentServerClientOptions());
930
+ await Promise.all(
931
+ planningConversationIds.map(async (planningConversationId) => {
932
+ try {
933
+ await client.deleteConversation(planningConversationId);
934
+ } catch (error) {
935
+ // Already gone (or unreachable): never block deleting the parent the
936
+ // user actually asked to remove.
937
+ console.warn(
938
+ `Failed to delete planning conversation ${planningConversationId}`,
939
+ error,
940
+ );
941
+ }
942
+ removeStoredConversationMetadata(planningConversationId);
943
+ }),
944
+ );
945
+
946
+ await client.deleteConversation(conversationId);
947
+ removeStoredConversationMetadata(conversationId);
948
+ }
949
+
950
+ static async updateConversationTitle(
951
+ conversationId: string,
952
+ title: string,
953
+ ): Promise<AppConversation> {
954
+ if (getActiveBackend().backend.kind === "cloud") {
955
+ return updateCloudConversationTitle(conversationId, title);
956
+ }
957
+
958
+ await new ConversationClient(
959
+ getAgentServerClientOptions(),
960
+ ).updateConversation(conversationId, {
961
+ title,
962
+ });
963
+ const [conversation] = await this.batchGetAppConversations([
964
+ conversationId,
965
+ ]);
966
+ return requireAppConversation(conversation, conversationId);
967
+ }
968
+
969
+ /**
970
+ * Replaces the conversation's complete server-side tag map (the PATCH is
971
+ * replace-all, so callers must merge user edits with any reserved/internal
972
+ * keys before calling). Mirrors `updateConversationTitle`; local
973
+ * agent-server conversations only — Cloud conversations don't carry tags.
974
+ */
975
+ static async updateConversationTags(
976
+ conversationId: string,
977
+ tags: Record<string, string>,
978
+ ): Promise<AppConversation> {
979
+ await new ConversationClient(
980
+ getAgentServerClientOptions(),
981
+ ).updateConversation(conversationId, {
982
+ tags,
983
+ });
984
+ const [conversation] = await this.batchGetAppConversations([
985
+ conversationId,
986
+ ]);
987
+ return requireAppConversation(conversation, conversationId);
988
+ }
989
+
990
+ /**
991
+ * Forks a conversation, copying event history up to and including
992
+ * `fromEventId`. Local agent-server only; needs agent-server >= 1.31.0 for
993
+ * `from_event_id` (older backends copy the whole conversation).
994
+ */
995
+ static async forkConversation(
996
+ sourceConversationId: string,
997
+ fromEventId: string,
998
+ title?: string,
999
+ ): Promise<DirectConversationInfo> {
1000
+ if (getActiveBackend().backend.kind === "cloud") {
1001
+ throw new Error(
1002
+ "Branching a conversation isn't supported on the cloud backend yet.",
1003
+ );
1004
+ }
1005
+
1006
+ // `from_event_id` is accepted by `/fork` but not yet typed in
1007
+ // ForkConversationRequest (through client 1.32.0); the client forwards the
1008
+ // body verbatim, so cast to carry it. A title also suppresses the backend
1009
+ // auto-title, so the "(branch)" marker sticks.
1010
+ const data = await new ConversationClient(
1011
+ getAgentServerClientOptions(),
1012
+ ).forkConversation<DirectConversationInfo>(sourceConversationId, {
1013
+ from_event_id: fromEventId,
1014
+ ...(title ? { title } : {}),
1015
+ } as ForkConversationRequest & { from_event_id: string });
1016
+
1017
+ // Carry over the source's client-side metadata (repo/branch/workspace/
1018
+ // profile/plugins) so the fork hydrates its chat-page badges the same way.
1019
+ const sourceMetadata = getStoredConversationMetadata(sourceConversationId);
1020
+ if (sourceMetadata) {
1021
+ setStoredConversationMetadata(data.id, sourceMetadata);
1022
+ }
1023
+
1024
+ return data;
1025
+ }
1026
+
1027
+ /**
1028
+ * Returns an event's `parent_id` (the fork point for branching *before* it),
1029
+ * or undefined at the root. Uses the single-event endpoint because the events
1030
+ * *search* API omits `parent_id`.
1031
+ */
1032
+ static async getEventParentId(
1033
+ conversationId: string,
1034
+ eventId: string,
1035
+ ): Promise<string | undefined> {
1036
+ const event = (await new ConversationClient(
1037
+ getAgentServerClientOptions(),
1038
+ ).getEvent(conversationId, eventId)) as { parent_id?: string | null };
1039
+ return event.parent_id ?? undefined;
1040
+ }
1041
+
1042
+ /**
1043
+ * Switches the LLM profile for the running conversation when one is open
1044
+ * (POST /switch_profile — per-conversation swap, doesn't change the user's
1045
+ * default profile). When called without a conversationId (home page),
1046
+ * falls back to POST /activate so the next conversation created picks up
1047
+ * the chosen profile.
1048
+ *
1049
+ * The per-conversation endpoint accepts only the profile name, so the UI does
1050
+ * not need to fetch or forward profile secrets. That keeps switching working
1051
+ * even when the agent server has no OH_SECRET_KEY for encrypted secret export.
1052
+ *
1053
+ * Cloud backends route to the app-server's per-conversation
1054
+ * `/switch_profile`, which owns the profiles and resolves the swap
1055
+ * server-side (base_url/api_key fixups, usage_id derivation, then the
1056
+ * agent-server's switch_llm) — so the client only forwards the profile name,
1057
+ * mirroring {@link switchAcpModel}.
1058
+ */
1059
+ static async switchProfile(
1060
+ conversationId: string | null,
1061
+ profileName: string,
1062
+ ): Promise<void> {
1063
+ const { backend } = getActiveBackend();
1064
+
1065
+ if (backend.kind === "cloud") {
1066
+ // No conversation (home page): activate globally so the next
1067
+ // conversation starts with it. ProfilesService routes to the cloud
1068
+ // activate endpoint.
1069
+ if (!conversationId) {
1070
+ await ProfilesService.activateProfile(profileName);
1071
+ return;
1072
+ }
1073
+ await callCloudProxy({
1074
+ backend,
1075
+ method: "POST",
1076
+ path: `/api/v1/app-conversations/${conversationId}/switch_profile`,
1077
+ body: { profile_name: profileName },
1078
+ });
1079
+ return;
1080
+ }
1081
+
1082
+ if (!conversationId) {
1083
+ await new ProfilesClient(getAgentServerClientOptions()).activateProfile(
1084
+ profileName,
1085
+ );
1086
+ return;
1087
+ }
1088
+
1089
+ const clientOptions = getAgentServerClientOptions();
1090
+ const conversationClient = new ConversationClient(clientOptions);
1091
+ const profile = await new ProfilesClient(clientOptions).getProfile(
1092
+ profileName,
1093
+ { exposeSecrets: "encrypted" },
1094
+ );
1095
+ const model =
1096
+ typeof profile.config.model === "string" ? profile.config.model : "";
1097
+ if (!model) throw new Error(`Profile '${profileName}' has no model.`);
1098
+ await assertSubscriptionAuthReady({ llm: profile.config });
1099
+ await conversationClient.switchLLM(conversationId, {
1100
+ ...profile.config,
1101
+ model,
1102
+ // Keep streaming on after a switch (parity with conversation start);
1103
+ // the profile config would otherwise default it to stream=False.
1104
+ stream: true,
1105
+ // Avoid stale first-write-wins entries in the backend LLM registry.
1106
+ usage_id: `profile:${profileName}:${uuidv4()}`,
1107
+ } as LLMConfig);
1108
+ }
1109
+
1110
+ /**
1111
+ * Switches the model of a running ACP conversation in place (POST
1112
+ * /switch_acp_model — the ACP analog of {@link switchProfile}'s /switch_profile).
1113
+ * The agent-server calls the ACP wrapper's ``session/set_model`` on the live
1114
+ * session, preserving context. Mirrors {@link switchProfile}'s
1115
+ * local-backend-only guard and per-conversation ConversationClient call.
1116
+ *
1117
+ * Works on a created-but-not-yet-run conversation too: the agent-server
1118
+ * treats a pre-first-run switch as a persist-only deferral (the model is
1119
+ * mirrored into the conversation's stored agent and applied when the session
1120
+ * starts). The home/no-conversation default is persisted via the active ACP
1121
+ * profile / Settings instead (see ``use-switch-acp-model``).
1122
+ */
1123
+ static async switchAcpModel(
1124
+ conversationId: string,
1125
+ model: string,
1126
+ ): Promise<void> {
1127
+ const { backend } = getActiveBackend();
1128
+ if (backend.kind === "cloud") {
1129
+ await callCloudProxy({
1130
+ backend,
1131
+ method: "POST",
1132
+ path: `/api/v1/app-conversations/${conversationId}/switch_acp_model`,
1133
+ body: { model },
1134
+ });
1135
+ return;
1136
+ }
1137
+
1138
+ await new ConversationClient(getAgentServerClientOptions()).switchAcpModel(
1139
+ conversationId,
1140
+ model,
1141
+ );
1142
+ }
1143
+ }
1144
+
1145
+ export default AgentServerConversationService;
src/api/conversation-service/agent-server-conversation-service.types.ts ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ConversationTrigger } from "../open-hands.types";
2
+ import { Provider } from "#/types/settings";
3
+ import { SuggestedTask } from "#/utils/types";
4
+ import { ExecutionStatus } from "#/types/agent-server/core";
5
+
6
+ /**
7
+ * Lifecycle state of a cloud sandbox. Mirrors OpenHands' V1SandboxStatus.
8
+ * Local agent-server conversations do not carry this field (null).
9
+ */
10
+ export type SandboxStatus =
11
+ | "PAUSED"
12
+ | "RUNNING"
13
+ | "STARTING"
14
+ | "MISSING"
15
+ | "ERROR";
16
+
17
+ // Plugin specification for starting conversations with plugins
18
+ export interface PluginSpec {
19
+ source: string; // Plugin source: 'github:owner/repo', git URL, or local path
20
+ ref?: string | null; // Optional branch, tag, or commit
21
+ repo_path?: string | null; // Subdirectory path within the git repository
22
+ parameters?: Record<string, unknown> | null; // User-provided configuration values
23
+ // Client-side display label (e.g. an installed plugin's `name`). Not a
24
+ // coordinate and not required by the agent-server; used only for rendering.
25
+ name?: string | null;
26
+ }
27
+
28
+ // V1 Metrics Types
29
+ export interface TokenUsage {
30
+ prompt_tokens: number;
31
+ completion_tokens: number;
32
+ cache_read_tokens: number;
33
+ cache_write_tokens: number;
34
+ context_window: number;
35
+ per_turn_token: number;
36
+ }
37
+
38
+ export interface MetricsSnapshot {
39
+ accumulated_cost: number | null;
40
+ max_budget_per_task: number | null;
41
+ accumulated_token_usage: TokenUsage | null;
42
+ }
43
+
44
+ // V1 API Types for requests
45
+ // These types match the SDK's TextContent and ImageContent formats
46
+ export interface MessageTextContent {
47
+ type: "text";
48
+ text: string;
49
+ }
50
+
51
+ export interface MessageImageContent {
52
+ type: "image";
53
+ image_urls: string[];
54
+ }
55
+
56
+ export type MessageContent = MessageTextContent | MessageImageContent;
57
+
58
+ type MessageRole = "user" | "system" | "assistant" | "tool";
59
+
60
+ export interface SendMessageRequest {
61
+ role: MessageRole;
62
+ content: MessageContent[];
63
+ }
64
+
65
+ export interface AppConversationStartRequest {
66
+ // Re-provision an EXISTING conversation (waking a recycled sandbox) instead
67
+ // of minting a new one. The backend keys the rebuilt conversation on this id
68
+ // and, for ACP, resumes it from the durable event store with a bootstrap
69
+ // prompt (OpenHands#14640). Omit/null to create a fresh conversation.
70
+ conversation_id?: string | null;
71
+ initial_message?: SendMessageRequest | null;
72
+ processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
73
+ llm_model?: string | null;
74
+ selected_repository?: string | null;
75
+ selected_branch?: string | null;
76
+ git_provider?: Provider | null;
77
+ suggested_task?: SuggestedTask | null;
78
+ title?: string | null;
79
+ trigger?: ConversationTrigger | null;
80
+ pr_number?: number[];
81
+ parent_conversation_id?: string | null;
82
+ agent_type?: "default" | "plan";
83
+ sandbox_id?: string | null;
84
+ plugins?: PluginSpec[] | null; // Plugins to load when starting the conversation
85
+ // One-off launch from a saved AgentProfile, resolved server-side (#3727).
86
+ // Accepted by the cloud app-server (OpenHands #15060) on
87
+ // POST /api/v1/app-conversations; the local path uses the encrypted
88
+ // agent_settings builder instead, which threads its own agentProfileId.
89
+ agent_profile_id?: string | null;
90
+ }
91
+
92
+ export type AppConversationStartTaskStatus =
93
+ | "WORKING"
94
+ | "WAITING_FOR_SANDBOX"
95
+ | "PREPARING_REPOSITORY"
96
+ | "RUNNING_SETUP_SCRIPT"
97
+ | "SETTING_UP_GIT_HOOKS"
98
+ | "SETTING_UP_SKILLS"
99
+ | "STARTING_CONVERSATION"
100
+ | "READY"
101
+ | "ERROR";
102
+
103
+ export interface AppConversationStartTask {
104
+ id: string;
105
+ created_by_user_id: string | null;
106
+ status: AppConversationStartTaskStatus;
107
+ detail: string | null;
108
+ app_conversation_id: string | null;
109
+ agent_server_url: string | null;
110
+ request: AppConversationStartRequest;
111
+ created_at: string;
112
+ updated_at: string;
113
+ }
114
+
115
+ export interface SendMessageResponse {
116
+ role: "user" | "system" | "assistant" | "tool";
117
+ content: MessageContent[];
118
+ }
119
+
120
+ export interface AppConversationStartTaskPage {
121
+ items: AppConversationStartTask[];
122
+ next_page_id: string | null;
123
+ }
124
+
125
+ export interface ConversationWorkspace {
126
+ working_dir: string | null;
127
+ }
128
+
129
+ export interface LaunchedAgentProfile {
130
+ agent_profile_id: string;
131
+ revision: number;
132
+ }
133
+
134
+ export interface AppConversation {
135
+ id: string;
136
+ created_by_user_id: string | null;
137
+ selected_repository: string | null;
138
+ selected_branch: string | null;
139
+ git_provider: Provider | null;
140
+ title: string | null;
141
+ trigger: ConversationTrigger | null;
142
+ pr_number: number[];
143
+ /**
144
+ * High-level kind of the conversation's agent — ``"openhands"`` for an LLM-
145
+ * driven Agent, ``"acp"`` for an ACPAgent that delegates to an external
146
+ * ACP CLI subprocess. Consumers can use this to gate UI affordances that
147
+ * only make sense for one kind (e.g. the LLM-profile switcher in the chat
148
+ * header is a no-op for ACP conversations even though ``llm_model`` may
149
+ * carry the ACP subprocess model for display).
150
+ */
151
+ agent_kind?: "openhands" | "acp" | null;
152
+ launched_agent_profile?: LaunchedAgentProfile | null;
153
+ /**
154
+ * For ACP conversations, the registry key of the ACP CLI server the
155
+ * conversation was launched against (e.g. ``"claude-code"``, ``"codex"``,
156
+ * ``"gemini-cli"``). Populated from ``info.tags.acpserver`` — see
157
+ * ``ACP_SERVER_TAG_KEY`` in ``agent-server-adapter.ts`` for the wire
158
+ * format and the rationale behind the snake_case-incompatible
159
+ * ``acpserver`` form. ``null`` for OpenHands conversations and for ACP
160
+ * conversations whose tag wasn't stamped (e.g. created via an older
161
+ * client or via the raw API). Consumers resolve the display name via
162
+ * ``getAcpProviderDisplayName(acp_server)`` and fall back to a generic
163
+ * "ACP" chip when the key is unknown or null.
164
+ */
165
+ acp_server?: string | null;
166
+ /**
167
+ * Server-side key-value tags from the agent-server's
168
+ * ``ConversationInfo.tags`` (settable at creation and via
169
+ * ``PATCH /api/conversations/{id}``). Automations and API clients use these
170
+ * for attribution (e.g. ``origin=slack``, ``owner=alice``). Includes
171
+ * reserved/internal keys such as ``acpserver`` — display consumers should
172
+ * go through ``getDisplayConversationTags`` in ``agent-server-adapter.ts``
173
+ * rather than reading this raw. ``null``/absent for Cloud conversations
174
+ * and agent-servers that don't return tags.
175
+ *
176
+ * PATCH semantics: the agent-server replaces the **complete** ``tags`` map
177
+ * on every PATCH — it does not merge individual keys. A writer adding one
178
+ * tag must read the current map, merge, and send the full desired result,
179
+ * or it will silently drop every other tag (including ``acpserver``).
180
+ */
181
+ tags?: Record<string, string> | null;
182
+ llm_model: string | null;
183
+ metrics: MetricsSnapshot | null;
184
+ created_at: string;
185
+ updated_at: string;
186
+ execution_status: ExecutionStatus | null;
187
+ /**
188
+ * Cloud-only sandbox lifecycle status. Mirrors OpenHands' V1SandboxStatus.
189
+ * Absent / null for local agent-server conversations.
190
+ */
191
+ sandbox_status?: SandboxStatus | null;
192
+ conversation_url: string | null;
193
+ session_api_key: string | null;
194
+ sandbox_id: string | null;
195
+ workspace?: ConversationWorkspace | null;
196
+ /**
197
+ * The local workspace the user explicitly attached when creating this
198
+ * conversation. Client-side only — never round-tripped to the agent-server
199
+ * or cloud. Null/undefined for conversations created via "No workspace".
200
+ * Distinct from `workspace.working_dir`, which is the runtime path and may
201
+ * either match this folder directly or point at a per-conversation worktree.
202
+ */
203
+ selected_workspace?: string | null;
204
+ /**
205
+ * The LLM profile this conversation was created with / last switched to.
206
+ * Hydrated from client-side metadata (see
207
+ * `ConversationMetadata.active_profile`). Preferred over matching
208
+ * `llm_model` against the profile list, which is ambiguous when several
209
+ * profiles share a model (#1082). Null when unknown (e.g. created by an
210
+ * older client) — consumers fall back to model-matching.
211
+ */
212
+ active_profile?: string | null;
213
+ public?: boolean;
214
+ sub_conversation_ids: string[];
215
+ }
216
+
217
+ export interface AppConversationPage {
218
+ items: AppConversation[];
219
+ next_page_id: string | null;
220
+ }
221
+
222
+ export interface Skill {
223
+ name: string;
224
+ type: "repo" | "knowledge" | "agentskills";
225
+ content: string;
226
+ triggers: string[];
227
+ source?: string | null;
228
+ description?: string | null;
229
+ version?: string;
230
+ license?: string | null;
231
+ compatibility?: string | null;
232
+ metadata?: Record<string, string> | null;
233
+ allowed_tools?: string[] | null;
234
+ is_agentskills_format?: boolean;
235
+ disable_model_invocation?: boolean;
236
+ }
237
+
238
+ export interface GetSkillsResponse {
239
+ skills: Skill[];
240
+ }
241
+
242
+ export interface HookDefinition {
243
+ type: string; // 'command' or 'prompt'
244
+ command: string;
245
+ timeout: number;
246
+ async?: boolean;
247
+ }
248
+
249
+ export interface HookMatcher {
250
+ matcher: string; // Pattern: '*', exact match, or regex
251
+ hooks?: HookDefinition[]; // May be undefined while hooks are still executing on the server
252
+ }
253
+
254
+ export interface HookEvent {
255
+ event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use'
256
+ matchers: HookMatcher[];
257
+ }
258
+
259
+ export interface GetHooksResponse {
260
+ hooks: HookEvent[];
261
+ }
262
+
263
+ // Runtime conversation types (from agent server)
264
+ export interface RuntimeConversationStats {
265
+ usage_to_metrics: Record<string, RuntimeMetrics>;
266
+ }
267
+
268
+ export interface RuntimeMetrics {
269
+ model_name: string;
270
+ accumulated_cost: number;
271
+ max_budget_per_task: number | null;
272
+ accumulated_token_usage: TokenUsage | null;
273
+ costs: Cost[];
274
+ response_latencies: ResponseLatency[];
275
+ token_usages: TokenUsage[];
276
+ }
277
+
278
+ export interface Cost {
279
+ model: string;
280
+ cost: number;
281
+ timestamp: number;
282
+ }
283
+
284
+ export interface ResponseLatency {
285
+ model: string;
286
+ latency: number;
287
+ response_id: string;
288
+ }
289
+
290
+ export interface RuntimeConversationInfo {
291
+ id: string;
292
+ title: string | null;
293
+ metrics: MetricsSnapshot | null;
294
+ created_at: string;
295
+ updated_at: string;
296
+ status: ExecutionStatus;
297
+ stats: RuntimeConversationStats;
298
+ }
src/api/conversation-service/conversation-service.api.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VSCodeClient } from "@openhands/typescript-client/clients";
2
+ import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
3
+ import { uploadFilesToConversation } from "#/api/conversation-file-upload.api";
4
+ import {
5
+ GetVSCodeUrlResponse,
6
+ GetTrajectoryResponse,
7
+ FileUploadSuccessResponse,
8
+ } from "../open-hands.types";
9
+ import { getAgentServerWorkingDir } from "../agent-server-config";
10
+ import {
11
+ getAgentServerClientOptions,
12
+ getAgentServerHttpClientOptions,
13
+ } from "../agent-server-client-options";
14
+ import { AppConversation } from "./agent-server-conversation-service.types";
15
+
16
+ class ConversationService {
17
+ private static currentConversation: AppConversation | null = null;
18
+
19
+ static setCurrentConversation(
20
+ currentConversation: AppConversation | null,
21
+ ): void {
22
+ this.currentConversation = currentConversation;
23
+ }
24
+
25
+ static getCurrentConversation(): AppConversation | null {
26
+ return this.currentConversation;
27
+ }
28
+
29
+ private static getClientOverrides() {
30
+ return {
31
+ sessionApiKey: this.currentConversation?.session_api_key,
32
+ };
33
+ }
34
+
35
+ static async getVSCodeUrl(
36
+ conversationId: string,
37
+ ): Promise<GetVSCodeUrlResponse> {
38
+ const workspaceDir =
39
+ this.currentConversation?.id === conversationId
40
+ ? (this.currentConversation?.workspace?.working_dir ??
41
+ getAgentServerWorkingDir())
42
+ : getAgentServerWorkingDir();
43
+ const vscodeUrl = await new VSCodeClient(
44
+ getAgentServerClientOptions(this.getClientOverrides()),
45
+ ).getUrl({
46
+ baseUrl:
47
+ typeof window !== "undefined" ? window.location.origin : undefined,
48
+ workspaceDir,
49
+ });
50
+
51
+ return { vscode_url: vscodeUrl };
52
+ }
53
+
54
+ static async getTrajectory(
55
+ conversationId: string,
56
+ ): Promise<GetTrajectoryResponse> {
57
+ const page = await new RemoteEventsList(
58
+ getAgentServerHttpClientOptions(this.getClientOverrides()),
59
+ conversationId,
60
+ ).search({ limit: 10000 });
61
+
62
+ return { trajectory: page.items ?? [] };
63
+ }
64
+
65
+ static async uploadFiles(
66
+ conversationId: string,
67
+ files: File[],
68
+ ): Promise<FileUploadSuccessResponse> {
69
+ return uploadFilesToConversation(
70
+ conversationId,
71
+ files,
72
+ this.currentConversation,
73
+ );
74
+ }
75
+ }
76
+
77
+ export default ConversationService;
src/api/event-service/event-service.api.test.ts ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const {
4
+ buildHttpBaseUrlMock,
5
+ callCloudProxyMock,
6
+ conversationClientConstructorMock,
7
+ getActiveBackendMock,
8
+ getAgentServerClientOptionsMock,
9
+ getAgentServerHttpClientOptionsMock,
10
+ getEventCountMock,
11
+ remoteEventsListConstructorMock,
12
+ remoteSearchMock,
13
+ respondToConfirmationMock,
14
+ } = vi.hoisted(() => ({
15
+ buildHttpBaseUrlMock: vi.fn(),
16
+ callCloudProxyMock: vi.fn(),
17
+ conversationClientConstructorMock: vi.fn(),
18
+ getActiveBackendMock: vi.fn(),
19
+ getAgentServerClientOptionsMock: vi.fn(),
20
+ getAgentServerHttpClientOptionsMock: vi.fn(),
21
+ getEventCountMock: vi.fn(),
22
+ remoteEventsListConstructorMock: vi.fn(),
23
+ remoteSearchMock: vi.fn(),
24
+ respondToConfirmationMock: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("@openhands/typescript-client/clients", () => ({
28
+ ConversationClient: class {
29
+ constructor(options: unknown) {
30
+ conversationClientConstructorMock(options);
31
+ }
32
+
33
+ respondToConfirmation = respondToConfirmationMock;
34
+ getEventCount = getEventCountMock;
35
+ },
36
+ }));
37
+ vi.mock("@openhands/typescript-client/events/remote-events-list", () => ({
38
+ RemoteEventsList: class {
39
+ constructor(options: unknown, conversationId: string) {
40
+ remoteEventsListConstructorMock(options, conversationId);
41
+ }
42
+
43
+ search = remoteSearchMock;
44
+ },
45
+ }));
46
+ vi.mock("#/utils/websocket-url", () => ({
47
+ buildHttpBaseUrl: buildHttpBaseUrlMock,
48
+ }));
49
+ vi.mock("../backend-registry/active-store", () => ({
50
+ getActiveBackend: getActiveBackendMock,
51
+ }));
52
+ vi.mock("../cloud/proxy", () => ({ callCloudProxy: callCloudProxyMock }));
53
+ vi.mock("../agent-server-client-options", () => ({
54
+ getAgentServerClientOptions: getAgentServerClientOptionsMock,
55
+ getAgentServerHttpClientOptions: getAgentServerHttpClientOptionsMock,
56
+ }));
57
+
58
+ import EventService from "./event-service.api";
59
+
60
+ const cloudBackend = {
61
+ id: "cloud-backend",
62
+ name: "Cloud",
63
+ host: "https://app.example.com",
64
+ apiKey: "cloud-api-key",
65
+ kind: "cloud",
66
+ };
67
+
68
+ const localBackend = {
69
+ id: "local-backend",
70
+ name: "Local",
71
+ host: "http://localhost:3000",
72
+ apiKey: "local-api-key",
73
+ kind: "local",
74
+ };
75
+
76
+ function useBackend(backend: typeof cloudBackend | typeof localBackend): void {
77
+ getActiveBackendMock.mockReturnValue({ backend, orgId: null });
78
+ }
79
+
80
+ describe("EventService", () => {
81
+ beforeEach(() => {
82
+ vi.resetAllMocks();
83
+ useBackend(cloudBackend);
84
+ buildHttpBaseUrlMock.mockReturnValue("https://runtime.example.com/base");
85
+ getAgentServerClientOptionsMock.mockReturnValue({
86
+ host: "http://local-client.example.com",
87
+ apiKey: "client-key",
88
+ workingDir: "workspace/project",
89
+ });
90
+ getAgentServerHttpClientOptionsMock.mockReturnValue({
91
+ baseUrl: "http://local-http.example.com",
92
+ apiKey: "http-key",
93
+ timeout: 60_000,
94
+ });
95
+ });
96
+
97
+ describe("respondToConfirmation", () => {
98
+ it("routes cloud confirmations through the runtime proxy with session authentication", async () => {
99
+ const request = { accept: false, reason: "Needs human review" };
100
+ callCloudProxyMock.mockResolvedValue({ success: true });
101
+
102
+ await expect(
103
+ EventService.respondToConfirmation(
104
+ "conversation-cloud",
105
+ "wss://runtime.example.com/base/api/conversations/conversation-cloud",
106
+ request,
107
+ "session-key",
108
+ ),
109
+ ).resolves.toEqual({ success: true });
110
+
111
+ expect(buildHttpBaseUrlMock).toHaveBeenCalledWith(
112
+ "wss://runtime.example.com/base/api/conversations/conversation-cloud",
113
+ );
114
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
115
+ backend: cloudBackend,
116
+ method: "POST",
117
+ hostOverride: "https://runtime.example.com/base",
118
+ path: "/api/conversations/conversation-cloud/events/respond_to_confirmation",
119
+ body: request,
120
+ authMode: "session-api-key",
121
+ sessionApiKey: "session-key",
122
+ });
123
+ expect(conversationClientConstructorMock).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("uses the typed conversation client for local confirmations", async () => {
127
+ useBackend(localBackend);
128
+ respondToConfirmationMock.mockResolvedValue({ success: false });
129
+
130
+ await expect(
131
+ EventService.respondToConfirmation(
132
+ "conversation-local",
133
+ "http://localhost:3000/api/conversations/conversation-local",
134
+ { accept: true },
135
+ ),
136
+ ).resolves.toEqual({ success: false });
137
+
138
+ expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith({
139
+ conversationUrl:
140
+ "http://localhost:3000/api/conversations/conversation-local",
141
+ sessionApiKey: undefined,
142
+ });
143
+ expect(conversationClientConstructorMock).toHaveBeenCalledWith({
144
+ host: "http://local-client.example.com",
145
+ apiKey: "client-key",
146
+ workingDir: "workspace/project",
147
+ });
148
+ expect(respondToConfirmationMock).toHaveBeenCalledWith(
149
+ "conversation-local",
150
+ { accept: true },
151
+ );
152
+ expect(callCloudProxyMock).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
156
+ describe("getEventCount", () => {
157
+ it("gets the cloud count from the runtime proxy", async () => {
158
+ callCloudProxyMock.mockResolvedValue(17);
159
+
160
+ await expect(
161
+ EventService.getEventCount(
162
+ "conversation-cloud",
163
+ "https://runtime.example.com/api/conversations/conversation-cloud",
164
+ null,
165
+ ),
166
+ ).resolves.toBe(17);
167
+
168
+ expect(buildHttpBaseUrlMock).toHaveBeenCalledWith(
169
+ "https://runtime.example.com/api/conversations/conversation-cloud",
170
+ );
171
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
172
+ backend: cloudBackend,
173
+ method: "GET",
174
+ hostOverride: "https://runtime.example.com/base",
175
+ path: "/api/conversations/conversation-cloud/events/count",
176
+ authMode: "session-api-key",
177
+ sessionApiKey: null,
178
+ });
179
+ expect(conversationClientConstructorMock).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("gets the local count from the typed conversation client", async () => {
183
+ useBackend(localBackend);
184
+ getEventCountMock.mockResolvedValue(8);
185
+
186
+ await expect(
187
+ EventService.getEventCount(
188
+ "conversation-local",
189
+ "http://localhost:3000/api/conversations/conversation-local",
190
+ "session-key",
191
+ ),
192
+ ).resolves.toBe(8);
193
+
194
+ expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith({
195
+ conversationUrl:
196
+ "http://localhost:3000/api/conversations/conversation-local",
197
+ sessionApiKey: "session-key",
198
+ });
199
+ expect(conversationClientConstructorMock).toHaveBeenCalledWith({
200
+ host: "http://local-client.example.com",
201
+ apiKey: "client-key",
202
+ workingDir: "workspace/project",
203
+ });
204
+ expect(getEventCountMock).toHaveBeenCalledWith("conversation-local");
205
+ expect(callCloudProxyMock).not.toHaveBeenCalled();
206
+ });
207
+ });
208
+
209
+ describe("searchEvents", () => {
210
+ it("caps cloud limits and forwards every supported history filter", async () => {
211
+ const event = { id: "event-1" };
212
+ callCloudProxyMock.mockResolvedValue({
213
+ items: [event],
214
+ next_page_id: "page-2",
215
+ });
216
+
217
+ await expect(
218
+ EventService.searchEvents("conversation-cloud", null, null, {
219
+ limit: 250,
220
+ pageId: "page-1",
221
+ sortOrder: "TIMESTAMP_DESC",
222
+ timestampGte: "2026-07-12T00:00:00.000Z",
223
+ timestampLt: "2026-07-13T00:00:00.000Z",
224
+ }),
225
+ ).resolves.toEqual({ items: [event], next_page_id: "page-2" });
226
+
227
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
228
+ backend: cloudBackend,
229
+ method: "GET",
230
+ path: "/api/v1/conversation/conversation-cloud/events/search?limit=100&sort_order=TIMESTAMP_DESC&page_id=page-1&timestamp__gte=2026-07-12T00%3A00%3A00.000Z&timestamp__lt=2026-07-13T00%3A00%3A00.000Z",
231
+ });
232
+ });
233
+
234
+ it("uses default cloud options and normalizes a missing response", async () => {
235
+ callCloudProxyMock.mockResolvedValue(undefined);
236
+
237
+ await expect(
238
+ EventService.searchEvents("conversation-cloud"),
239
+ ).resolves.toEqual({ items: [], next_page_id: null });
240
+
241
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
242
+ backend: cloudBackend,
243
+ method: "GET",
244
+ path: "/api/v1/conversation/conversation-cloud/events/search?limit=100",
245
+ });
246
+ });
247
+
248
+ it("preserves zero as an explicit cloud limit", async () => {
249
+ callCloudProxyMock.mockResolvedValue({});
250
+
251
+ await expect(
252
+ EventService.searchEvents("conversation-cloud", null, null, {
253
+ limit: 0,
254
+ }),
255
+ ).resolves.toEqual({ items: [], next_page_id: null });
256
+
257
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
258
+ backend: cloudBackend,
259
+ method: "GET",
260
+ path: "/api/v1/conversation/conversation-cloud/events/search?limit=0",
261
+ });
262
+ });
263
+
264
+ it("rethrows cloud failures when no pagination filter was requested", async () => {
265
+ const searchError = new Error("cloud unavailable");
266
+ callCloudProxyMock.mockRejectedValue(searchError);
267
+
268
+ await expect(
269
+ EventService.searchEvents("conversation-cloud", null, null, {
270
+ limit: 25,
271
+ }),
272
+ ).rejects.toBe(searchError);
273
+ });
274
+
275
+ it("rethrows filtered cloud failures for completeness-sensitive callers", async () => {
276
+ const paginationError = new Error("pagination unsupported");
277
+ callCloudProxyMock.mockRejectedValue(paginationError);
278
+
279
+ await expect(
280
+ EventService.searchEvents("conversation-cloud", null, null, {
281
+ pageId: "page-2",
282
+ strictPagination: true,
283
+ }),
284
+ ).rejects.toBe(paginationError);
285
+ });
286
+
287
+ it("retains the empty-page fallback for ordinary cloud pagination", async () => {
288
+ callCloudProxyMock.mockRejectedValue(new Error("pagination unsupported"));
289
+ const warn = vi
290
+ .spyOn(console, "warn")
291
+ .mockImplementation(() => undefined);
292
+
293
+ await expect(
294
+ EventService.searchEvents("conversation-cloud", null, null, {
295
+ timestampLt: "2026-07-10T12:34:56.000Z",
296
+ }),
297
+ ).resolves.toEqual({ items: [], next_page_id: null });
298
+
299
+ expect(warn).toHaveBeenCalledWith(
300
+ "[EventService] Cloud backend doesn't support pagination filters. " +
301
+ "Falling back to initial load only. " +
302
+ "Server needs OpenHands/OpenHands#14399.",
303
+ );
304
+ warn.mockRestore();
305
+ });
306
+
307
+ it("treats a sort-only cloud failure as unsupported filtered pagination", async () => {
308
+ callCloudProxyMock.mockRejectedValue(new Error("pagination unsupported"));
309
+ const warn = vi
310
+ .spyOn(console, "warn")
311
+ .mockImplementation(() => undefined);
312
+
313
+ await expect(
314
+ EventService.searchEvents("conversation-cloud", null, null, {
315
+ sortOrder: "TIMESTAMP_DESC",
316
+ }),
317
+ ).resolves.toEqual({ items: [], next_page_id: null });
318
+
319
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
320
+ backend: cloudBackend,
321
+ method: "GET",
322
+ path: "/api/v1/conversation/conversation-cloud/events/search?limit=100&sort_order=TIMESTAMP_DESC",
323
+ });
324
+ expect(warn).toHaveBeenCalledOnce();
325
+ warn.mockRestore();
326
+ });
327
+
328
+ it("recognizes a lower timestamp bound as a cloud pagination filter", async () => {
329
+ callCloudProxyMock.mockResolvedValue({
330
+ items: [],
331
+ next_page_id: null,
332
+ });
333
+
334
+ await EventService.searchEvents("conversation-cloud", null, null, {
335
+ timestampGte: "2026-07-10T12:34:56.000Z",
336
+ });
337
+
338
+ expect(callCloudProxyMock).toHaveBeenCalledWith({
339
+ backend: cloudBackend,
340
+ method: "GET",
341
+ path: "/api/v1/conversation/conversation-cloud/events/search?limit=100&timestamp__gte=2026-07-10T12%3A34%3A56.000Z",
342
+ });
343
+ });
344
+
345
+ it("passes all local filters to the remote events list", async () => {
346
+ useBackend(localBackend);
347
+ const event = { id: "event-local" };
348
+ remoteSearchMock.mockResolvedValue({
349
+ items: [event],
350
+ next_page_id: "local-page-2",
351
+ });
352
+
353
+ await expect(
354
+ EventService.searchEvents(
355
+ "conversation-local",
356
+ "http://localhost:3000/api/conversations/conversation-local",
357
+ "session-key",
358
+ {
359
+ limit: 40,
360
+ pageId: "local-page-1",
361
+ sortOrder: "TIMESTAMP",
362
+ timestampGte: "2026-07-11T00:00:00.000Z",
363
+ timestampLt: "2026-07-12T00:00:00.000Z",
364
+ },
365
+ ),
366
+ ).resolves.toEqual({
367
+ items: [event],
368
+ next_page_id: "local-page-2",
369
+ });
370
+
371
+ expect(getAgentServerHttpClientOptionsMock).toHaveBeenCalledWith({
372
+ conversationUrl:
373
+ "http://localhost:3000/api/conversations/conversation-local",
374
+ sessionApiKey: "session-key",
375
+ });
376
+ expect(remoteEventsListConstructorMock).toHaveBeenCalledWith(
377
+ {
378
+ baseUrl: "http://local-http.example.com",
379
+ apiKey: "http-key",
380
+ timeout: 60_000,
381
+ },
382
+ "conversation-local",
383
+ );
384
+ expect(remoteSearchMock).toHaveBeenCalledWith({
385
+ limit: 40,
386
+ page_id: "local-page-1",
387
+ sort_order: "TIMESTAMP",
388
+ timestamp__gte: "2026-07-11T00:00:00.000Z",
389
+ timestamp__lt: "2026-07-12T00:00:00.000Z",
390
+ });
391
+ expect(callCloudProxyMock).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("uses default local options and normalizes a missing page", async () => {
395
+ useBackend(localBackend);
396
+ remoteSearchMock.mockResolvedValue(undefined);
397
+
398
+ await expect(
399
+ EventService.searchEvents("conversation-local"),
400
+ ).resolves.toEqual({ items: [], next_page_id: null });
401
+
402
+ expect(getAgentServerHttpClientOptionsMock).toHaveBeenCalledWith({
403
+ conversationUrl: undefined,
404
+ sessionApiKey: undefined,
405
+ });
406
+ expect(remoteSearchMock).toHaveBeenCalledWith({ limit: 100 });
407
+ });
408
+ });
409
+ });
src/api/event-service/event-service.api.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ConversationClient } from "@openhands/typescript-client/clients";
2
+ import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list";
3
+ import { OpenHandsEvent } from "#/types/agent-server/core";
4
+ import { buildHttpBaseUrl } from "#/utils/websocket-url";
5
+ import { getActiveBackend } from "../backend-registry/active-store";
6
+ import { callCloudProxy } from "../cloud/proxy";
7
+ import {
8
+ getAgentServerClientOptions,
9
+ getAgentServerHttpClientOptions,
10
+ } from "../agent-server-client-options";
11
+ import type {
12
+ ConfirmationResponseRequest,
13
+ ConfirmationResponseResponse,
14
+ EventSearchOptions,
15
+ EventSearchPage,
16
+ } from "./event-service.types";
17
+
18
+ /**
19
+ * Cloud-mode REST calls are split between two upstream hosts (matching
20
+ * OpenHands' cloud frontend):
21
+ *
22
+ * - **App API** (`backend.host`, default in `callCloudProxy`):
23
+ * event *history* (`/api/v1/conversation/{id}/events/search`).
24
+ * Persisted by the cloud backend — survives the runtime sandbox.
25
+ *
26
+ * - **Runtime sandbox** (extracted from `conversation.conversation_url`
27
+ * and passed as `hostOverride`): live runtime endpoints like
28
+ * `/api/conversations/{id}/events/count` and
29
+ * `/api/conversations/{id}/events/respond_to_confirmation`. Auth on
30
+ * these endpoints is `X-Session-API-Key`, not `Authorization: Bearer`.
31
+ *
32
+ * App API calls go directly to the cloud backend with bearer auth. Runtime
33
+ * sandbox calls go through `/api/cloud-proxy`, which avoids depending on CORS
34
+ * for per-conversation runtime hosts.
35
+ *
36
+ * Local mode keeps the existing typescript-client path: it targets the
37
+ * conversation's host directly via typed client classes.
38
+ */
39
+ class EventService {
40
+ static async respondToConfirmation(
41
+ conversationId: string,
42
+ conversationUrl: string,
43
+ request: ConfirmationResponseRequest,
44
+ sessionApiKey?: string | null,
45
+ ): Promise<ConfirmationResponseResponse> {
46
+ const active = getActiveBackend().backend;
47
+
48
+ if (active.kind === "cloud") {
49
+ return callCloudProxy<ConfirmationResponseResponse>({
50
+ backend: active,
51
+ method: "POST",
52
+ hostOverride: buildHttpBaseUrl(conversationUrl),
53
+ path: `/api/conversations/${conversationId}/events/respond_to_confirmation`,
54
+ body: request,
55
+ authMode: "session-api-key",
56
+ sessionApiKey,
57
+ });
58
+ }
59
+
60
+ return new ConversationClient(
61
+ getAgentServerClientOptions({
62
+ conversationUrl,
63
+ sessionApiKey,
64
+ }),
65
+ ).respondToConfirmation<ConfirmationResponseResponse>(
66
+ conversationId,
67
+ request,
68
+ );
69
+ }
70
+
71
+ static async getEventCount(
72
+ conversationId: string,
73
+ conversationUrl: string,
74
+ sessionApiKey?: string | null,
75
+ ): Promise<number> {
76
+ const active = getActiveBackend().backend;
77
+
78
+ if (active.kind === "cloud") {
79
+ return callCloudProxy<number>({
80
+ backend: active,
81
+ method: "GET",
82
+ hostOverride: buildHttpBaseUrl(conversationUrl),
83
+ path: `/api/conversations/${conversationId}/events/count`,
84
+ authMode: "session-api-key",
85
+ sessionApiKey,
86
+ });
87
+ }
88
+
89
+ return new ConversationClient(
90
+ getAgentServerClientOptions({
91
+ conversationUrl,
92
+ sessionApiKey,
93
+ }),
94
+ ).getEventCount(conversationId);
95
+ }
96
+
97
+ /**
98
+ * Search events for a conversation. Returns the raw page so callers can
99
+ * paginate (via `next_page_id`) and so REST-driven history loading can
100
+ * tell when there are no more older events to load.
101
+ */
102
+ static async searchEvents(
103
+ conversationId: string,
104
+ conversationUrl?: string | null,
105
+ sessionApiKey?: string | null,
106
+ options: EventSearchOptions = {},
107
+ ): Promise<EventSearchPage<OpenHandsEvent>> {
108
+ const active = getActiveBackend().backend;
109
+ const limit = options.limit ?? 100;
110
+
111
+ if (active.kind === "cloud") {
112
+ // Event *history* lives on the cloud App API, not the runtime
113
+ // sandbox. Path is singular `conversation` and v1-prefixed.
114
+ //
115
+ // Full pagination params (sort_order, page_id, timestamp filters)
116
+ // require the server-side fix from OpenHands/OpenHands#14399. If
117
+ // the cloud backend hasn't been updated yet, the timestamp filters
118
+ // trigger a 500 (str-vs-datetime comparison). We attempt the full
119
+ // request first and fall back to a limit-only request on failure.
120
+ const cloudLimit = Math.min(limit, 100);
121
+ const hasFilterParams = !!(
122
+ options.sortOrder ||
123
+ options.pageId ||
124
+ options.timestampGte ||
125
+ options.timestampLt
126
+ );
127
+
128
+ const params = new URLSearchParams();
129
+ params.set("limit", String(cloudLimit));
130
+ if (options.sortOrder) params.set("sort_order", options.sortOrder);
131
+ if (options.pageId) params.set("page_id", options.pageId);
132
+ if (options.timestampGte)
133
+ params.set("timestamp__gte", options.timestampGte);
134
+ if (options.timestampLt) params.set("timestamp__lt", options.timestampLt);
135
+
136
+ const doCloudSearch = (searchParams: URLSearchParams) =>
137
+ callCloudProxy<EventSearchPage<OpenHandsEvent>>({
138
+ backend: active,
139
+ method: "GET",
140
+ path: `/api/v1/conversation/${conversationId}/events/search?${searchParams.toString()}`,
141
+ });
142
+
143
+ try {
144
+ const data = await doCloudSearch(params);
145
+ return {
146
+ items: data?.items ?? [],
147
+ next_page_id: data?.next_page_id ?? null,
148
+ };
149
+ } catch (err) {
150
+ if (!hasFilterParams) throw err;
151
+ if (options.strictPagination) throw err;
152
+
153
+ // Server doesn't support timestamp filters yet — stop pagination
154
+ // by returning an empty page so the UI doesn't retry indefinitely.
155
+ // A limit-only fallback would return the same most-recent events
156
+ // already in the store, which get deduped but keep hasMore=true.
157
+ console.warn(
158
+ "[EventService] Cloud backend doesn't support pagination filters. " +
159
+ "Falling back to initial load only. " +
160
+ "Server needs OpenHands/OpenHands#14399.",
161
+ );
162
+ return { items: [], next_page_id: null };
163
+ }
164
+ }
165
+
166
+ const page = await new RemoteEventsList(
167
+ getAgentServerHttpClientOptions({ conversationUrl, sessionApiKey }),
168
+ conversationId,
169
+ ).search({
170
+ limit,
171
+ ...(options.pageId ? { page_id: options.pageId } : {}),
172
+ ...(options.sortOrder ? { sort_order: options.sortOrder } : {}),
173
+ ...(options.timestampGte ? { timestamp__gte: options.timestampGte } : {}),
174
+ ...(options.timestampLt ? { timestamp__lt: options.timestampLt } : {}),
175
+ });
176
+
177
+ return {
178
+ items: (page?.items ?? []) as OpenHandsEvent[],
179
+ next_page_id: page?.next_page_id ?? null,
180
+ };
181
+ }
182
+ }
183
+
184
+ export default EventService;
src/api/event-service/event-service.types.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface ConfirmationResponseRequest {
2
+ accept: boolean;
3
+ reason?: string;
4
+ }
5
+
6
+ export interface ConfirmationResponseResponse {
7
+ success: boolean;
8
+ }
9
+
10
+ export type EventSortOrder = "TIMESTAMP" | "TIMESTAMP_DESC";
11
+
12
+ export interface EventSearchOptions {
13
+ /** Maximum number of events to return per page (server caps at 100). */
14
+ limit?: number;
15
+ /** Page ID for pagination. */
16
+ pageId?: string;
17
+ /** Sort order for the underlying server query. */
18
+ sortOrder?: EventSortOrder;
19
+ /** Filter: event timestamp >= this value (ISO 8601). */
20
+ timestampGte?: string;
21
+ /** Filter: event timestamp < this value (ISO 8601). */
22
+ timestampLt?: string;
23
+ /**
24
+ * Surface unsupported cloud pagination instead of degrading to an empty
25
+ * page. Callers that require a complete result (such as transcript export)
26
+ * must set this so a partial history is never mistaken for exhaustion.
27
+ */
28
+ strictPagination?: boolean;
29
+ }
30
+
31
+ export interface EventSearchPage<TEvent> {
32
+ items: TEvent[];
33
+ next_page_id?: string | null;
34
+ }
src/api/git-service/agent-server-git-service.api.ts ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { isAxiosError } from "axios";
2
+ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
3
+ import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper";
4
+ import { buildHttpBaseUrl } from "#/utils/websocket-url";
5
+ import type {
6
+ GitChange,
7
+ GitChangeDiff,
8
+ GitCommitsPage,
9
+ } from "../open-hands.types";
10
+ import { getActiveBackend } from "../backend-registry/active-store";
11
+ import { callCloudProxy } from "../cloud/proxy";
12
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
13
+ import { isSdkHttpStatusError } from "../agent-server-compatibility";
14
+
15
+ interface AgentServerGitChange {
16
+ status: string;
17
+ path: string;
18
+ }
19
+
20
+ interface AgentServerGitCommit {
21
+ sha: string;
22
+ short_sha: string;
23
+ subject: string;
24
+ author: string;
25
+ timestamp: string;
26
+ }
27
+
28
+ interface AgentServerGitCommitsPage {
29
+ commits: AgentServerGitCommit[];
30
+ has_more: boolean;
31
+ }
32
+
33
+ /**
34
+ * Git operations for agent-server conversations.
35
+ *
36
+ * In **local** mode the runtime is reachable directly from the browser
37
+ * (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land
38
+ * fine. In **cloud** mode the runtime is at
39
+ * `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from
40
+ * `localhost`. So cloud-mode calls hit the cloud API's
41
+ * `GET /api/v1/app-conversations/{id}/git/{changes,diff}` proxy
42
+ * endpoints instead — the server resolves the conversation's runtime
43
+ * and makes the hop itself with the sandbox's session API key, and the
44
+ * cloud API's CORS is permissive for bearer-token requests.
45
+ */
46
+
47
+ /**
48
+ * The cloud runtime's `/api/git/{changes,diff}` endpoints prepend
49
+ * `/workspace/` to relative paths (so a relative arg like
50
+ * `workspace/project` becomes `/workspace/workspace/project` and 404s).
51
+ * `getGitPath` returns the local agent-server's relative convention by
52
+ * default; normalize to an absolute path before sending to the cloud
53
+ * runtime.
54
+ */
55
+ function toAbsoluteRuntimePath(path: string): string {
56
+ return path.startsWith("/") ? path : `/${path}`;
57
+ }
58
+
59
+ /**
60
+ * GET an arbitrary runtime git endpoint in both backend modes. Local mode
61
+ * uses the SDK's public `HttpClient` (the typed `gitChanges`/`gitDiff`
62
+ * wrappers don't know the newer endpoints/params, and they re-wrap errors,
63
+ * dropping the `.status` needed for 404 feature-detection). Cloud mode goes
64
+ * through the generic cloud-proxy envelope with the sandbox's session API
65
+ * key — the same hop `executeCommand`/`downloadFile` use — so no dedicated
66
+ * cloud API endpoints are required.
67
+ */
68
+ async function getFromRuntime<T>(
69
+ conversationUrl: string | null | undefined,
70
+ sessionApiKey: string | null | undefined,
71
+ apiPath: string,
72
+ params: { path: string } & Record<string, string>,
73
+ ): Promise<T> {
74
+ const active = getActiveBackend().backend;
75
+
76
+ if (active.kind === "cloud" && conversationUrl) {
77
+ const search = new URLSearchParams({
78
+ ...params,
79
+ path: toAbsoluteRuntimePath(params.path),
80
+ });
81
+ return callCloudProxy<T>({
82
+ backend: active,
83
+ method: "GET",
84
+ hostOverride: buildHttpBaseUrl(conversationUrl),
85
+ path: `${apiPath}?${search.toString()}`,
86
+ authMode: "session-api-key",
87
+ sessionApiKey: sessionApiKey ?? undefined,
88
+ });
89
+ }
90
+
91
+ const response = await new RemoteWorkspace(
92
+ getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
93
+ ).client.get<T>(apiPath, { params });
94
+ return response.data;
95
+ }
96
+
97
+ /**
98
+ * The commit endpoints are newer than some deployed agent servers. A 404
99
+ * (route absent) means "server too old" — callers hide the commits section
100
+ * instead of surfacing an error. Local calls and cloud-proxy calls (via the
101
+ * shared TypeScript client) both throw the SDK's `HttpError`; the axios
102
+ * check is kept as a safety net for axios-shaped errors.
103
+ */
104
+ function isEndpointMissingError(error: unknown): boolean {
105
+ return (
106
+ isSdkHttpStatusError(error, 404) ||
107
+ (isAxiosError(error) && error.response?.status === 404)
108
+ );
109
+ }
110
+
111
+ class AgentServerGitService {
112
+ static async getGitChanges(
113
+ conversationId: string,
114
+ conversationUrl: string | null | undefined,
115
+ sessionApiKey: string | null | undefined,
116
+ path: string,
117
+ ): Promise<GitChange[]> {
118
+ const active = getActiveBackend().backend;
119
+
120
+ if (active.kind === "cloud" && conversationId) {
121
+ const params = new URLSearchParams();
122
+ params.set("path", toAbsoluteRuntimePath(path));
123
+ const data = await callCloudProxy<AgentServerGitChange[]>({
124
+ backend: active,
125
+ method: "GET",
126
+ path: `/api/v1/app-conversations/${conversationId}/git/changes?${params.toString()}`,
127
+ });
128
+ if (!Array.isArray(data)) {
129
+ throw new Error(
130
+ "Invalid response from runtime - runtime may be unavailable",
131
+ );
132
+ }
133
+ return data.map((change) => ({
134
+ status: mapAnyGitStatusToClientStatus(
135
+ String(change.status) as Parameters<
136
+ typeof mapAnyGitStatusToClientStatus
137
+ >[0],
138
+ ),
139
+ path: change.path,
140
+ }));
141
+ }
142
+
143
+ // No `ref`: let the server auto-detect the base (origin branch /
144
+ // merge-base), so changes the agent has already committed still show
145
+ // up. `ref: "HEAD"` would go blank after every `git commit` — and the
146
+ // cloud-proxy branch above already omits `ref`.
147
+ const changes = await new RemoteWorkspace(
148
+ getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
149
+ ).gitChanges(path);
150
+
151
+ if (!Array.isArray(changes)) {
152
+ throw new Error(
153
+ "Invalid response from runtime - runtime may be unavailable",
154
+ );
155
+ }
156
+
157
+ return changes.map((change) => ({
158
+ status: mapAnyGitStatusToClientStatus(
159
+ String(change.status) as Parameters<
160
+ typeof mapAnyGitStatusToClientStatus
161
+ >[0],
162
+ ),
163
+ path: change.path,
164
+ }));
165
+ }
166
+
167
+ /**
168
+ * List the conversation's commits (display-base..HEAD, newest first).
169
+ * Resolves to `null` when the agent server predates the endpoint (404),
170
+ * so callers can hide the commits section instead of erroring.
171
+ */
172
+ static async getGitCommits(
173
+ conversationUrl: string | null | undefined,
174
+ sessionApiKey: string | null | undefined,
175
+ path: string,
176
+ limit = 50,
177
+ ): Promise<GitCommitsPage | null> {
178
+ try {
179
+ const page = await getFromRuntime<AgentServerGitCommitsPage>(
180
+ conversationUrl,
181
+ sessionApiKey,
182
+ "/api/git/commits",
183
+ { path, limit: String(limit) },
184
+ );
185
+ return {
186
+ commits: (page?.commits ?? []).map((commit) => ({
187
+ sha: commit.sha,
188
+ shortSha: commit.short_sha,
189
+ subject: commit.subject,
190
+ author: commit.author,
191
+ timestamp: commit.timestamp,
192
+ })),
193
+ hasMore: Boolean(page?.has_more),
194
+ };
195
+ } catch (error) {
196
+ if (isEndpointMissingError(error)) return null;
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ /** Files changed by a single commit (vs its first parent). */
202
+ static async getCommitChanges(
203
+ conversationUrl: string | null | undefined,
204
+ sessionApiKey: string | null | undefined,
205
+ path: string,
206
+ sha: string,
207
+ ): Promise<GitChange[]> {
208
+ const changes = await getFromRuntime<AgentServerGitChange[]>(
209
+ conversationUrl,
210
+ sessionApiKey,
211
+ `/api/git/commits/${encodeURIComponent(sha)}/changes`,
212
+ { path },
213
+ );
214
+
215
+ if (!Array.isArray(changes)) {
216
+ throw new Error(
217
+ "Invalid response from runtime - runtime may be unavailable",
218
+ );
219
+ }
220
+
221
+ return changes.map((change) => ({
222
+ status: mapAnyGitStatusToClientStatus(
223
+ String(change.status) as Parameters<
224
+ typeof mapAnyGitStatusToClientStatus
225
+ >[0],
226
+ ),
227
+ path: change.path,
228
+ }));
229
+ }
230
+
231
+ static async getGitChangeDiff(
232
+ conversationId: string,
233
+ conversationUrl: string | null | undefined,
234
+ sessionApiKey: string | null | undefined,
235
+ path: string,
236
+ commit?: string,
237
+ ): Promise<GitChangeDiff> {
238
+ if (commit) {
239
+ // Per-commit diff: both sides come from git objects on the server,
240
+ // so files the commit deleted still render.
241
+ const commitDiff = await getFromRuntime<
242
+ GitChangeDiff & { diff?: string }
243
+ >(conversationUrl, sessionApiKey, "/api/git/diff", { path, commit });
244
+ return {
245
+ modified: commitDiff?.modified ?? "",
246
+ original: commitDiff?.original ?? "",
247
+ ...(commitDiff?.diff ? { diff: commitDiff.diff } : {}),
248
+ } as GitChangeDiff;
249
+ }
250
+
251
+ const active = getActiveBackend().backend;
252
+
253
+ if (active.kind === "cloud" && conversationId) {
254
+ const params = new URLSearchParams();
255
+ params.set("path", toAbsoluteRuntimePath(path));
256
+ const diff = await callCloudProxy<GitChangeDiff & { diff?: string }>({
257
+ backend: active,
258
+ method: "GET",
259
+ path: `/api/v1/app-conversations/${conversationId}/git/diff?${params.toString()}`,
260
+ });
261
+ return {
262
+ modified: diff?.modified ?? "",
263
+ original: diff?.original ?? "",
264
+ ...(diff?.diff ? { diff: diff.diff } : {}),
265
+ } as GitChangeDiff;
266
+ }
267
+
268
+ // No `ref` for the same reason as getGitChanges: the base must match
269
+ // the one the change list was computed against.
270
+ const diff = (await new RemoteWorkspace(
271
+ getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
272
+ ).gitDiff(path)) as GitChangeDiff & { diff?: string };
273
+
274
+ return {
275
+ modified: diff.modified ?? "",
276
+ original: diff.original ?? "",
277
+ ...(diff.diff ? { diff: diff.diff } : {}),
278
+ } as GitChangeDiff;
279
+ }
280
+ }
281
+
282
+ export default AgentServerGitService;
src/api/git-service/git-service.api.ts ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
2
+ import { RepositoryPage, BranchPage, InstallationPage } from "#/types/git";
3
+ import { Provider } from "#/types/settings";
4
+ import { GitChange, GitChangeDiff } from "../open-hands.types";
5
+ import AgentServerConversationService from "../conversation-service/agent-server-conversation-service.api";
6
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
7
+ import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper";
8
+ import { getActiveBackend } from "../backend-registry/active-store";
9
+ import {
10
+ getCloudInstallations,
11
+ getCloudRepositoryBranches,
12
+ searchCloudRepositories,
13
+ } from "../cloud/git-service.api";
14
+
15
+ const safeProvider = (value: string): Provider => value as Provider;
16
+
17
+ const isCloudActive = () => getActiveBackend().backend.kind === "cloud";
18
+
19
+ /**
20
+ * Guard against null/undefined provider values that would result in
21
+ * invalid API requests (e.g., "?provider=undefined"). Returns true
22
+ * if the provider is falsy and the request should be skipped.
23
+ */
24
+ const isInvalidProvider = (provider: string | null | undefined): boolean =>
25
+ !provider || provider === "undefined" || provider === "null";
26
+
27
+ const EMPTY_REPOSITORY_PAGE: RepositoryPage = { items: [], next_page_id: null };
28
+ const EMPTY_BRANCH_PAGE: BranchPage = { items: [], next_page_id: null };
29
+ const EMPTY_INSTALLATION_PAGE: InstallationPage = {
30
+ items: [],
31
+ next_page_id: null,
32
+ };
33
+
34
+ class GitService {
35
+ static async searchGitRepositories(
36
+ query: string,
37
+ provider: string,
38
+ limit = 100,
39
+ pageId?: string,
40
+ installationId?: string,
41
+ ): Promise<RepositoryPage> {
42
+ if (isInvalidProvider(provider) || !isCloudActive()) {
43
+ return EMPTY_REPOSITORY_PAGE;
44
+ }
45
+ return searchCloudRepositories({
46
+ provider: safeProvider(provider),
47
+ query: query || undefined,
48
+ limit,
49
+ pageId,
50
+ installationId,
51
+ });
52
+ }
53
+
54
+ static async retrieveUserGitRepositories(
55
+ provider: string,
56
+ pageId?: string,
57
+ limit = 30,
58
+ installationId?: string,
59
+ ): Promise<RepositoryPage> {
60
+ if (isInvalidProvider(provider) || !isCloudActive()) {
61
+ return EMPTY_REPOSITORY_PAGE;
62
+ }
63
+ return searchCloudRepositories({
64
+ provider: safeProvider(provider),
65
+ limit,
66
+ pageId,
67
+ installationId,
68
+ });
69
+ }
70
+
71
+ static async retrieveInstallationRepositories(
72
+ provider: string,
73
+ installationIndex: number,
74
+ installations: string[],
75
+ pageId?: string,
76
+ limit = 30,
77
+ ): Promise<RepositoryPage> {
78
+ if (isInvalidProvider(provider) || !isCloudActive()) {
79
+ return EMPTY_REPOSITORY_PAGE;
80
+ }
81
+ const installationId = installations[installationIndex];
82
+ if (!installationId) return EMPTY_REPOSITORY_PAGE;
83
+ return searchCloudRepositories({
84
+ provider: safeProvider(provider),
85
+ installationId,
86
+ limit,
87
+ pageId,
88
+ });
89
+ }
90
+
91
+ static async getRepositoryBranches(
92
+ repository: string,
93
+ provider: string,
94
+ query: string = "",
95
+ pageId?: string,
96
+ limit = 30,
97
+ ): Promise<BranchPage> {
98
+ if (isInvalidProvider(provider) || !isCloudActive()) {
99
+ return EMPTY_BRANCH_PAGE;
100
+ }
101
+ return getCloudRepositoryBranches({
102
+ provider: safeProvider(provider),
103
+ repository,
104
+ query: query || undefined,
105
+ pageId,
106
+ limit,
107
+ });
108
+ }
109
+
110
+ static async searchRepositoryBranches(
111
+ repository: string,
112
+ provider: string,
113
+ query: string,
114
+ pageId?: string,
115
+ limit = 30,
116
+ ): Promise<BranchPage> {
117
+ if (isInvalidProvider(provider) || !isCloudActive()) {
118
+ return EMPTY_BRANCH_PAGE;
119
+ }
120
+ return getCloudRepositoryBranches({
121
+ provider: safeProvider(provider),
122
+ repository,
123
+ query,
124
+ pageId,
125
+ limit,
126
+ });
127
+ }
128
+
129
+ static async getUserInstallations(
130
+ provider: string,
131
+ pageId?: string,
132
+ limit = 100,
133
+ ): Promise<InstallationPage> {
134
+ if (isInvalidProvider(provider) || !isCloudActive()) {
135
+ return EMPTY_INSTALLATION_PAGE;
136
+ }
137
+ return getCloudInstallations({
138
+ provider: safeProvider(provider),
139
+ pageId,
140
+ limit,
141
+ });
142
+ }
143
+
144
+ static async getGitChanges(conversationId: string): Promise<GitChange[]> {
145
+ const workingDir =
146
+ await AgentServerConversationService.resolveConversationWorkingDir(
147
+ conversationId,
148
+ );
149
+ const changes = await new RemoteWorkspace(
150
+ getAgentServerClientOptions({ workingDir }),
151
+ ).gitChanges(workingDir);
152
+
153
+ return changes.map((change) => ({
154
+ path: change.path,
155
+ status: mapAnyGitStatusToClientStatus(
156
+ String(change.status) as Parameters<
157
+ typeof mapAnyGitStatusToClientStatus
158
+ >[0],
159
+ ),
160
+ }));
161
+ }
162
+
163
+ static async getGitChangeDiff(
164
+ _conversationId: string,
165
+ path: string,
166
+ ): Promise<GitChangeDiff> {
167
+ const diff = await new RemoteWorkspace(
168
+ getAgentServerClientOptions(),
169
+ ).gitDiff(path);
170
+
171
+ return {
172
+ modified: diff.modified ?? "",
173
+ original: diff.original ?? "",
174
+ };
175
+ }
176
+ }
177
+
178
+ export default GitService;
src/api/mcp-health/mcp-health-store.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { McpServerHealth } from "#/types/mcp-health";
2
+
3
+ type Listener = () => void;
4
+
5
+ export type McpHealthMap = Record<string, McpServerHealth>;
6
+
7
+ /**
8
+ * In-memory MCP connection-health store, keyed by
9
+ * `getMcpServerHealthKey(server)`. Mirrors the module-store shape of
10
+ * `#/api/backend-registry/health-store` but is deliberately NOT persisted:
11
+ * a health verdict is only as fresh as its probe, so a page reload resets
12
+ * every server to "unchecked" instead of resurrecting a possibly-stale
13
+ * "healthy".
14
+ */
15
+ let healthMap: McpHealthMap = {};
16
+ const listeners = new Set<Listener>();
17
+ let nextCheckId = 1;
18
+
19
+ function commit(next: McpHealthMap): void {
20
+ healthMap = next;
21
+ listeners.forEach((listener) => listener());
22
+ }
23
+
24
+ export function getMcpHealthSnapshot(): McpHealthMap {
25
+ return healthMap;
26
+ }
27
+
28
+ export function subscribeMcpHealth(listener: Listener): () => void {
29
+ listeners.add(listener);
30
+ return () => {
31
+ listeners.delete(listener);
32
+ };
33
+ }
34
+
35
+ /** Mark a probe as in flight and return its id for `resolveMcpHealthCheck`. */
36
+ export function beginMcpHealthCheck(key: string): number {
37
+ const checkId = nextCheckId;
38
+ nextCheckId += 1;
39
+ commit({ ...healthMap, [key]: { status: "checking", checkId } });
40
+ return checkId;
41
+ }
42
+
43
+ /**
44
+ * Commit a probe result, but only while the entry is still the `checking`
45
+ * state that probe created. A slow older probe can never overwrite a newer
46
+ * probe's result, and a probe whose server was edited, deleted, or reseeded
47
+ * mid-flight (entry cleared or replaced) is silently dropped — a stale
48
+ * check can never land a false verdict.
49
+ */
50
+ export function resolveMcpHealthCheck(
51
+ key: string,
52
+ checkId: number,
53
+ health: McpServerHealth,
54
+ ): void {
55
+ const current = healthMap[key];
56
+ if (current?.status !== "checking" || current.checkId !== checkId) {
57
+ return;
58
+ }
59
+ commit({ ...healthMap, [key]: health });
60
+ }
61
+
62
+ /** Unconditional write — used to seed health from a pre-save test result. */
63
+ export function setMcpServerHealth(key: string, health: McpServerHealth): void {
64
+ commit({ ...healthMap, [key]: health });
65
+ }
66
+
67
+ /** Drop the entry (back to "unchecked") — used when a server changes or is deleted. */
68
+ export function clearMcpServerHealth(key: string): void {
69
+ if (!(key in healthMap)) return;
70
+ const { [key]: _removed, ...rest } = healthMap;
71
+ commit(rest);
72
+ }
73
+
74
+ /** Test-only: reset state and listeners. */
75
+ export function __resetMcpHealthStoreForTests(): void {
76
+ healthMap = {};
77
+ listeners.clear();
78
+ nextCheckId = 1;
79
+ }
src/api/mcp-health/probe-mcp-server-health.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import McpService from "#/api/mcp-service/mcp-service.api";
2
+ import type { McpServerHealth } from "#/types/mcp-health";
3
+ import type {
4
+ ExtendedMCPTestResponse,
5
+ MCPServerConfig,
6
+ } from "#/types/mcp-server";
7
+ import { getCredentialValidationForServer } from "#/utils/mcp-credential-validation";
8
+ import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key";
9
+ import { redactMcpSecrets } from "#/utils/redact-mcp-secrets";
10
+ import {
11
+ beginMcpHealthCheck,
12
+ resolveMcpHealthCheck,
13
+ setMcpServerHealth,
14
+ } from "./mcp-health-store";
15
+
16
+ /**
17
+ * Conservative auth-failure sniffing for hosted servers that reject a bad
18
+ * token at the HTTP handshake: the backend can only classify those as
19
+ * `connection`/`unknown` (the MCP connection itself failed), but the
20
+ * guidance the user needs is the credentials one.
21
+ */
22
+ const AUTH_FAILURE_TEXT =
23
+ /\b(401|403)\b|unauthorized|forbidden|invalid[ _-]?(token|credentials?|api[ _-]?key)/i;
24
+
25
+ function failedHealth(
26
+ server: MCPServerConfig,
27
+ error: unknown,
28
+ ): McpServerHealth {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return {
31
+ status: "failed",
32
+ kind: "unknown",
33
+ error: redactMcpSecrets(message, server),
34
+ checkedAt: Date.now(),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Map a test/OAuth probe response (already secret-redacted and
40
+ * credential-interpreted by `McpService`) to a card health state.
41
+ *
42
+ * `verified` requires the catalog entry's read-only probe tool to have been
43
+ * advertised AND invoked without error; everything else that connected is
44
+ * `connectivity-only` — truthfully labeled, never silently upgraded.
45
+ */
46
+ export function interpretMcpTestResponse(
47
+ server: MCPServerConfig,
48
+ response: ExtendedMCPTestResponse,
49
+ ): McpServerHealth {
50
+ const checkedAt = Date.now();
51
+ if (!response.ok) {
52
+ const kind =
53
+ (response.error_kind === "connection" ||
54
+ response.error_kind === "unknown") &&
55
+ AUTH_FAILURE_TEXT.test(response.error)
56
+ ? "credentials"
57
+ : response.error_kind;
58
+ return { status: "failed", kind, error: response.error, checkedAt };
59
+ }
60
+ const validation = getCredentialValidationForServer(server);
61
+ const verified =
62
+ !!validation &&
63
+ response.tools.includes(validation.toolCall.name) &&
64
+ !!response.tool_result &&
65
+ !response.tool_result.is_error;
66
+ return {
67
+ status: "healthy",
68
+ verification: verified ? "verified" : "connectivity-only",
69
+ toolCount: response.tools.length,
70
+ checkedAt,
71
+ };
72
+ }
73
+
74
+ /** Run the non-mutating connection probe and publish the result. */
75
+ export async function probeMcpServerHealth(
76
+ server: MCPServerConfig,
77
+ ): Promise<void> {
78
+ const key = getMcpServerHealthKey(server);
79
+ const checkId = beginMcpHealthCheck(key);
80
+ let health: McpServerHealth;
81
+ try {
82
+ const response = await McpService.testServer(server);
83
+ health = interpretMcpTestResponse(server, response);
84
+ } catch (error) {
85
+ health = failedHealth(server, error);
86
+ }
87
+ resolveMcpHealthCheck(key, checkId, health);
88
+ }
89
+
90
+ /**
91
+ * Run the interactive OAuth authorization probe and publish the result.
92
+ * Returns the raw response (null on transport error) so the caller can
93
+ * persist a refreshed `oauth_state`.
94
+ */
95
+ export async function reauthorizeMcpServerHealth(
96
+ server: MCPServerConfig,
97
+ ): Promise<ExtendedMCPTestResponse | null> {
98
+ const key = getMcpServerHealthKey(server);
99
+ const checkId = beginMcpHealthCheck(key);
100
+ try {
101
+ const response = await McpService.authorizeOAuth(server);
102
+ resolveMcpHealthCheck(
103
+ key,
104
+ checkId,
105
+ interpretMcpTestResponse(server, response),
106
+ );
107
+ return response;
108
+ } catch (error) {
109
+ resolveMcpHealthCheck(key, checkId, failedHealth(server, error));
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Publish a just-saved server's health from the pre-save test result the
116
+ * install/edit flow already produced (same config, moments earlier), so its
117
+ * card shows a verdict immediately without a second probe.
118
+ *
119
+ * `otherServers` are the servers whose health must NOT be overwritten —
120
+ * callers pass the pre-save list, excluding the server being saved. When a
121
+ * same-shape duplicate exists (health keys exclude secret values, so two
122
+ * installs of one catalog entry can collide until the save suffixes the
123
+ * name), the seed is skipped and the new card simply starts "unchecked".
124
+ */
125
+ export function seedMcpServerHealth(
126
+ server: MCPServerConfig,
127
+ response: ExtendedMCPTestResponse,
128
+ otherServers: MCPServerConfig[],
129
+ ): void {
130
+ const key = getMcpServerHealthKey(server);
131
+ if (otherServers.some((other) => getMcpServerHealthKey(other) === key)) {
132
+ return;
133
+ }
134
+ setMcpServerHealth(key, interpretMcpTestResponse(server, response));
135
+ }
src/api/mcp-service/mcp-redacted-credentials.test.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import SettingsService, {
3
+ type SettingsApiResponse,
4
+ } from "#/api/settings-service/settings-service.api";
5
+ import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials";
6
+ import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
7
+
8
+ describe("substituteRedactedMcpCredentials", () => {
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ it("preserves encrypted stdio env when the server is renamed", async () => {
14
+ // Regression: renaming a stdio server left a redacted env value unchanged,
15
+ // so the lookup by the new display name missed the stored entry and the
16
+ // literal redaction placeholder overwrote the stored encrypted secret.
17
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
18
+ agent_settings: {
19
+ mcp_config: {
20
+ "old-name": {
21
+ command: "npx",
22
+ env: { API_KEY: "gAAAAA-encrypted-api-key" },
23
+ },
24
+ },
25
+ },
26
+ } as unknown as SettingsApiResponse);
27
+
28
+ const result = await substituteRedactedMcpCredentials({
29
+ id: "old-name",
30
+ type: "stdio",
31
+ name: "new-name",
32
+ command: "npx",
33
+ env: { API_KEY: REDACTED_MCP_SECRET_VALUE },
34
+ });
35
+
36
+ expect(result.env).toEqual({ API_KEY: "gAAAAA-encrypted-api-key" });
37
+ expect(result.name).toBe("new-name");
38
+ });
39
+
40
+ it("does not restore another server's secret when renamed onto an existing name", async () => {
41
+ // Renaming "alpha" onto "beta"'s name must still resolve alpha's stored
42
+ // entry by stable key, not beta's entry.
43
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
44
+ agent_settings: {
45
+ mcp_config: {
46
+ alpha: { command: "npx", env: { TOKEN: "gAAAAA-alpha-token" } },
47
+ beta: { command: "npx", env: { TOKEN: "gAAAAA-beta-token" } },
48
+ },
49
+ },
50
+ } as unknown as SettingsApiResponse);
51
+
52
+ const result = await substituteRedactedMcpCredentials({
53
+ id: "alpha",
54
+ type: "stdio",
55
+ name: "beta",
56
+ command: "npx",
57
+ env: { TOKEN: REDACTED_MCP_SECRET_VALUE },
58
+ });
59
+
60
+ expect(result.env).toEqual({ TOKEN: "gAAAAA-alpha-token" });
61
+ });
62
+
63
+ it("leaves typed (non-redacted) env values untouched", async () => {
64
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
65
+ agent_settings: {
66
+ mcp_config: {
67
+ "my-server": {
68
+ command: "npx",
69
+ env: { API_KEY: "gAAAAA-encrypted", REGION: "us-east-1" },
70
+ },
71
+ },
72
+ },
73
+ } as unknown as SettingsApiResponse);
74
+
75
+ const result = await substituteRedactedMcpCredentials({
76
+ id: "my-server",
77
+ type: "stdio",
78
+ name: "my-server",
79
+ command: "npx",
80
+ env: {
81
+ API_KEY: REDACTED_MCP_SECRET_VALUE,
82
+ REGION: "eu-west-1",
83
+ },
84
+ });
85
+
86
+ expect(result.env).toEqual({
87
+ API_KEY: "gAAAAA-encrypted",
88
+ REGION: "eu-west-1",
89
+ });
90
+ });
91
+
92
+ it("returns the server unchanged when no env value is redacted", async () => {
93
+ const server = {
94
+ id: "stdio-0",
95
+ type: "stdio" as const,
96
+ name: "my-server",
97
+ command: "npx",
98
+ env: { API_KEY: "plaintext" },
99
+ };
100
+
101
+ const result = await substituteRedactedMcpCredentials(server);
102
+
103
+ expect(result).toBe(server);
104
+ expect(SettingsService.fetchSettingsFromApi).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("keeps the placeholder when the stored stdio entry is missing", async () => {
108
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
109
+ agent_settings: { mcp_config: {} },
110
+ } as unknown as SettingsApiResponse);
111
+
112
+ const result = await substituteRedactedMcpCredentials({
113
+ id: "stdio-0",
114
+ type: "stdio",
115
+ name: "new-name",
116
+ command: "npx",
117
+ env: { API_KEY: REDACTED_MCP_SECRET_VALUE },
118
+ });
119
+
120
+ expect(result.env).toEqual({ API_KEY: REDACTED_MCP_SECRET_VALUE });
121
+ });
122
+
123
+ it("replaces redacted OAuth state with the encrypted stored subtree", async () => {
124
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
125
+ agent_settings: {
126
+ mcp_config: {
127
+ "superhuman-mail": {
128
+ url: "https://mcp.mail.superhuman.com/mcp",
129
+ auth: {
130
+ strategy: "oauth2",
131
+ state: {
132
+ tokens: {
133
+ access_token: "gAAAAA-encrypted-access-token",
134
+ refresh_token: "gAAAAA-encrypted-refresh-token",
135
+ },
136
+ client_info: {
137
+ client_id: "superhuman-client",
138
+ client_secret: "gAAAAA-encrypted-client-secret",
139
+ },
140
+ },
141
+ },
142
+ },
143
+ },
144
+ },
145
+ } as unknown as SettingsApiResponse);
146
+
147
+ const result = await substituteRedactedMcpCredentials({
148
+ id: "superhuman-mail",
149
+ type: "shttp",
150
+ name: "superhuman-mail",
151
+ url: "https://mcp.mail.superhuman.com/mcp",
152
+ auth: {
153
+ strategy: "oauth2",
154
+ state: {
155
+ tokens: {
156
+ access_token: REDACTED_MCP_SECRET_VALUE,
157
+ refresh_token: REDACTED_MCP_SECRET_VALUE,
158
+ },
159
+ client_info: {
160
+ client_id: "superhuman-client",
161
+ client_secret: REDACTED_MCP_SECRET_VALUE,
162
+ },
163
+ },
164
+ },
165
+ });
166
+
167
+ expect(result.auth).toEqual({
168
+ strategy: "oauth2",
169
+ state: {
170
+ tokens: {
171
+ access_token: "gAAAAA-encrypted-access-token",
172
+ refresh_token: "gAAAAA-encrypted-refresh-token",
173
+ },
174
+ client_info: {
175
+ client_id: "superhuman-client",
176
+ client_secret: "gAAAAA-encrypted-client-secret",
177
+ },
178
+ },
179
+ });
180
+ });
181
+
182
+ it("preserves fresh OAuth edits while substituting only redacted leaves", async () => {
183
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
184
+ agent_settings: {
185
+ mcp_config: {
186
+ "superhuman-mail": {
187
+ url: "https://mcp.mail.superhuman.com/mcp",
188
+ auth: {
189
+ strategy: "oauth2",
190
+ state: {
191
+ tokens: {
192
+ access_token: "gAAAAA-encrypted-access-token",
193
+ refresh_token: "gAAAAA-encrypted-refresh-token",
194
+ },
195
+ client_info: {
196
+ client_id: "old-client-id",
197
+ client_secret: "gAAAAA-encrypted-client-secret",
198
+ },
199
+ },
200
+ },
201
+ },
202
+ },
203
+ },
204
+ } as unknown as SettingsApiResponse);
205
+
206
+ const result = await substituteRedactedMcpCredentials({
207
+ id: "superhuman-mail",
208
+ type: "shttp",
209
+ name: "superhuman-mail",
210
+ url: "https://mcp.mail.superhuman.com/mcp",
211
+ auth: {
212
+ strategy: "oauth2",
213
+ state: {
214
+ tokens: {
215
+ access_token: REDACTED_MCP_SECRET_VALUE,
216
+ refresh_token: REDACTED_MCP_SECRET_VALUE,
217
+ },
218
+ client_info: {
219
+ client_id: "new-client-id",
220
+ client_secret: REDACTED_MCP_SECRET_VALUE,
221
+ },
222
+ },
223
+ },
224
+ });
225
+
226
+ expect(result.auth).toEqual({
227
+ strategy: "oauth2",
228
+ state: {
229
+ tokens: {
230
+ access_token: "gAAAAA-encrypted-access-token",
231
+ refresh_token: "gAAAAA-encrypted-refresh-token",
232
+ },
233
+ client_info: {
234
+ client_id: "new-client-id",
235
+ client_secret: "gAAAAA-encrypted-client-secret",
236
+ },
237
+ },
238
+ });
239
+ });
240
+
241
+ it("preserves fresh header-auth edits while substituting only redacted leaves", async () => {
242
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
243
+ agent_settings: {
244
+ mcp_config: {
245
+ mail: {
246
+ url: "https://mail.example/mcp",
247
+ auth: {
248
+ strategy: "header",
249
+ headers: {
250
+ "X-API-Key": "gAAAAA-encrypted-api-key",
251
+ "X-Region": "us-east-1",
252
+ },
253
+ },
254
+ },
255
+ },
256
+ },
257
+ } as unknown as SettingsApiResponse);
258
+
259
+ const result = await substituteRedactedMcpCredentials({
260
+ id: "mail",
261
+ type: "shttp",
262
+ name: "mail",
263
+ url: "https://mail.example/mcp",
264
+ auth: {
265
+ strategy: "header",
266
+ headers: {
267
+ "X-API-Key": REDACTED_MCP_SECRET_VALUE,
268
+ "X-Region": "eu-west-1",
269
+ },
270
+ },
271
+ });
272
+
273
+ expect(result.auth).toEqual({
274
+ strategy: "header",
275
+ headers: {
276
+ "X-API-Key": "gAAAAA-encrypted-api-key",
277
+ "X-Region": "eu-west-1",
278
+ },
279
+ });
280
+ });
281
+
282
+ it("replaces redacted raw remote headers with encrypted stored values", async () => {
283
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
284
+ agent_settings: {
285
+ mcp_config: {
286
+ github: {
287
+ url: "https://github.example/mcp",
288
+ headers: {
289
+ Authorization: "Bearer gAAAAA-encrypted-github-token",
290
+ },
291
+ },
292
+ },
293
+ },
294
+ } as unknown as SettingsApiResponse);
295
+
296
+ const result = await substituteRedactedMcpCredentials({
297
+ id: "github",
298
+ type: "shttp",
299
+ name: "github",
300
+ url: "https://github.example/mcp",
301
+ headers: { Authorization: REDACTED_MCP_SECRET_VALUE },
302
+ });
303
+
304
+ expect(result.headers).toEqual({
305
+ Authorization: "Bearer gAAAAA-encrypted-github-token",
306
+ });
307
+ });
308
+ });
src/api/mcp-service/mcp-redacted-credentials.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import SettingsService from "#/api/settings-service/settings-service.api";
2
+ import { isMcpAuthCredential } from "#/types/mcp-auth";
3
+ import type { MCPServerConfig } from "#/types/mcp-server";
4
+ import {
5
+ hasRedactedMcpSecretLeaf,
6
+ REDACTED_MCP_SECRET_VALUE,
7
+ } from "#/utils/mcp-config";
8
+
9
+ type StoredMcpServer = {
10
+ url?: unknown;
11
+ transport?: unknown;
12
+ env?: unknown;
13
+ auth?: unknown;
14
+ headers?: unknown;
15
+ };
16
+
17
+ type StoredMcpConfig = Record<string, StoredMcpServer>;
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ !!value && typeof value === "object" && !Array.isArray(value);
21
+
22
+ const stringRecord = (value: unknown): Record<string, string> | undefined => {
23
+ if (!isRecord(value)) return undefined;
24
+ const entries = Object.entries(value).filter(
25
+ (entry): entry is [string, string] => typeof entry[1] === "string",
26
+ );
27
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
28
+ };
29
+
30
+ const hasRedactedValue = (values: Record<string, string> | undefined) =>
31
+ !!values &&
32
+ Object.values(values).some((value) => value === REDACTED_MCP_SECRET_VALUE);
33
+
34
+ /**
35
+ * Recursively replace only the redacted display placeholders inside
36
+ * ``submitted`` with the corresponding stored (encrypted) leaf. Fresh
37
+ * non-redacted edits and untouched structure are preserved verbatim, so a
38
+ * probe request exercises the server exactly as it will be persisted.
39
+ */
40
+ function substituteRedactedLeaves<T>(submitted: T, stored: unknown): T {
41
+ if (!isRecord(submitted)) return submitted;
42
+
43
+ const storedRecord = isRecord(stored) ? stored : {};
44
+ const merged: Record<string, unknown> = { ...submitted };
45
+ for (const [key, value] of Object.entries(submitted)) {
46
+ if (value === REDACTED_MCP_SECRET_VALUE) {
47
+ const storedValue = storedRecord[key];
48
+ if (
49
+ typeof storedValue === "string" &&
50
+ storedValue !== REDACTED_MCP_SECRET_VALUE
51
+ ) {
52
+ merged[key] = storedValue;
53
+ }
54
+ } else if (isRecord(value)) {
55
+ merged[key] = substituteRedactedLeaves(value, storedRecord[key]);
56
+ }
57
+ }
58
+ return merged as T;
59
+ }
60
+
61
+ const findStoredServer = (
62
+ server: MCPServerConfig,
63
+ storedServers: StoredMcpConfig,
64
+ ): StoredMcpServer | undefined => storedServers[server.id];
65
+
66
+ async function fetchEncryptedStoredServer(
67
+ server: MCPServerConfig,
68
+ ): Promise<StoredMcpServer | undefined> {
69
+ const response = await SettingsService.fetchSettingsFromApi("encrypted");
70
+ const mcpConfig = response.agent_settings?.mcp_config;
71
+ if (!isRecord(mcpConfig)) {
72
+ return undefined;
73
+ }
74
+ return findStoredServer(server, mcpConfig as StoredMcpConfig);
75
+ }
76
+
77
+ /**
78
+ * The MCP editor sees redacted settings (`**********`). When the user leaves
79
+ * a secret unchanged, replace that placeholder with the stored encrypted
80
+ * env/header/OAuth state value so a connectivity test can exercise the real
81
+ * credential without exposing plaintext in the browser. Persistence never
82
+ * calls this helper; sparse settings patches omit unchanged secrets.
83
+ */
84
+ export async function substituteRedactedMcpCredentials(
85
+ server: MCPServerConfig,
86
+ ): Promise<MCPServerConfig> {
87
+ const redactedStdioEnv =
88
+ server.type === "stdio" && hasRedactedValue(server.env);
89
+ const redactedRemoteAuth =
90
+ (server.type === "sse" || server.type === "shttp") &&
91
+ hasRedactedMcpSecretLeaf(server.auth);
92
+ const redactedRemoteHeaders =
93
+ (server.type === "sse" || server.type === "shttp") &&
94
+ hasRedactedValue(server.headers);
95
+
96
+ if (!redactedStdioEnv && !redactedRemoteAuth && !redactedRemoteHeaders) {
97
+ return server;
98
+ }
99
+
100
+ try {
101
+ const stored = await fetchEncryptedStoredServer(server);
102
+ if (!stored) return server;
103
+
104
+ if (redactedStdioEnv) {
105
+ const storedEnv = stringRecord(stored.env) ?? {};
106
+ const env = Object.fromEntries(
107
+ Object.entries(server.env ?? {}).map(([key, value]) => [
108
+ key,
109
+ value === REDACTED_MCP_SECRET_VALUE &&
110
+ typeof storedEnv[key] === "string"
111
+ ? storedEnv[key]
112
+ : value,
113
+ ]),
114
+ );
115
+ return { ...server, env };
116
+ }
117
+
118
+ const nextServer = { ...server };
119
+ if (redactedRemoteAuth && isMcpAuthCredential(stored.auth)) {
120
+ nextServer.auth = substituteRedactedLeaves(server.auth, stored.auth);
121
+ }
122
+ if (redactedRemoteHeaders) {
123
+ const storedHeaders = stringRecord(stored.headers) ?? {};
124
+ nextServer.headers = Object.fromEntries(
125
+ Object.entries(server.headers ?? {}).map(([key, value]) => [
126
+ key,
127
+ value === REDACTED_MCP_SECRET_VALUE &&
128
+ typeof storedHeaders[key] === "string"
129
+ ? storedHeaders[key]
130
+ : value,
131
+ ]),
132
+ );
133
+ }
134
+ return nextServer;
135
+ } catch {
136
+ return server;
137
+ }
138
+ }
src/api/mcp-service/mcp-service.api.test.ts ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { MCPClient } from "@openhands/typescript-client/clients";
3
+ import {
4
+ setActiveSelection,
5
+ setRegisteredBackends,
6
+ } from "../backend-registry/active-store";
7
+ import SettingsService from "../settings-service/settings-service.api";
8
+ import McpService from "./mcp-service.api";
9
+ import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config";
10
+
11
+ vi.mock("@openhands/typescript-client/clients", () => ({
12
+ MCPClient: vi.fn(),
13
+ }));
14
+
15
+ const testServer = vi.fn();
16
+ const startOAuth = vi.fn();
17
+ const getOAuthStatus = vi.fn();
18
+ const submitOAuthCallback = vi.fn();
19
+ const close = vi.fn();
20
+
21
+ const encryptedAuth = "gAAAAAencrypted-auth";
22
+
23
+ describe("McpService.testServer", () => {
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ setRegisteredBackends([
27
+ {
28
+ id: "local",
29
+ name: "Local",
30
+ host: "http://127.0.0.1:8001",
31
+ apiKey: "session-key",
32
+ kind: "local",
33
+ },
34
+ ]);
35
+ setActiveSelection({ backendId: "local", orgId: null });
36
+ vi.mocked(MCPClient).mockImplementation(function MockMCPClient() {
37
+ return {
38
+ testServer,
39
+ startOAuth,
40
+ getOAuthStatus,
41
+ submitOAuthCallback,
42
+ close,
43
+ } as unknown as MCPClient;
44
+ } as unknown as typeof MCPClient);
45
+ testServer.mockResolvedValue({ ok: true, tools: [] });
46
+ startOAuth.mockResolvedValue({
47
+ ok: true,
48
+ job_id: "job-1",
49
+ authorization_url: "https://auth.example/authorize",
50
+ });
51
+ getOAuthStatus.mockResolvedValue({
52
+ ok: true,
53
+ status: "succeeded",
54
+ job_id: "job-1",
55
+ tools: ["search_mail"],
56
+ });
57
+ submitOAuthCallback.mockResolvedValue({
58
+ ok: true,
59
+ status: "succeeded",
60
+ job_id: "job-1",
61
+ tools: ["search_mail"],
62
+ });
63
+ });
64
+
65
+ it("tests stored remote MCP credentials as encrypted auth, not redacted text", async () => {
66
+ vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({
67
+ llm_api_key_is_set: false,
68
+ conversation_settings: {},
69
+ agent_settings: {
70
+ mcp_config: {
71
+ linear: {
72
+ url: "https://mcp.linear.app/mcp",
73
+ transport: "http",
74
+ auth: { strategy: "bearer", value: encryptedAuth },
75
+ },
76
+ },
77
+ },
78
+ });
79
+
80
+ await McpService.testServer({
81
+ id: "linear",
82
+ type: "shttp",
83
+ name: "linear",
84
+ url: "https://mcp.linear.app/mcp",
85
+ auth: { strategy: "bearer", value: REDACTED_MCP_SECRET_VALUE },
86
+ });
87
+
88
+ expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith(
89
+ "encrypted",
90
+ );
91
+ expect(testServer).toHaveBeenCalledTimes(1);
92
+ expect(testServer.mock.calls[0][0]).toMatchObject({
93
+ name: "linear",
94
+ server: {
95
+ type: "http",
96
+ url: "https://mcp.linear.app/mcp",
97
+ auth: { strategy: "bearer", value: encryptedAuth },
98
+ },
99
+ });
100
+ expect(testServer.mock.calls[0][0].server).not.toHaveProperty("api_key");
101
+ expect(testServer.mock.calls[0][0].server).not.toHaveProperty("headers");
102
+ expect(close).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ it("forwards explicit OAuth authentication metadata to the MCP test endpoint", async () => {
106
+ await McpService.testServer({
107
+ id: "shttp-0",
108
+ type: "shttp",
109
+ name: "superhuman-mail",
110
+ url: "https://mcp.mail.superhuman.com/mcp",
111
+ auth: {
112
+ strategy: "oauth2",
113
+ authentication: {
114
+ type: "oauth",
115
+ client_auth_method: "none",
116
+ },
117
+ state: {
118
+ tokens: {
119
+ access_token: "gAAAAexisting-access-token",
120
+ },
121
+ },
122
+ },
123
+ });
124
+
125
+ expect(testServer).toHaveBeenCalledTimes(1);
126
+ expect(testServer.mock.calls[0][0]).toMatchObject({
127
+ name: "superhuman-mail",
128
+ server: {
129
+ type: "http",
130
+ url: "https://mcp.mail.superhuman.com/mcp",
131
+ auth: {
132
+ strategy: "oauth2",
133
+ authentication: {
134
+ type: "oauth",
135
+ client_auth_method: "none",
136
+ },
137
+ state: {
138
+ tokens: {
139
+ access_token: "gAAAAexisting-access-token",
140
+ },
141
+ },
142
+ },
143
+ },
144
+ timeout: 120,
145
+ });
146
+ expect(close).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ it("returns OAuth state captured by the MCP test endpoint", async () => {
150
+ testServer.mockResolvedValueOnce({
151
+ ok: true,
152
+ tools: ["search_mail"],
153
+ oauth_state: {
154
+ tokens: {
155
+ access_token: "gAAAAencrypted-access-token",
156
+ },
157
+ token_expires_at: 12345,
158
+ },
159
+ });
160
+
161
+ const result = await McpService.testServer({
162
+ id: "shttp-0",
163
+ type: "shttp",
164
+ name: "superhuman-mail",
165
+ url: "https://mcp.mail.superhuman.com/mcp",
166
+ auth: {
167
+ strategy: "oauth2",
168
+ authentication: {
169
+ type: "oauth",
170
+ client_auth_method: "none",
171
+ },
172
+ },
173
+ });
174
+
175
+ expect(result.ok).toBe(true);
176
+ if (!result.ok) throw new Error("expected successful MCP test");
177
+ expect(result.oauth_state).toMatchObject({
178
+ tokens: {
179
+ access_token: "gAAAAencrypted-access-token",
180
+ },
181
+ token_expires_at: 12345,
182
+ });
183
+ });
184
+
185
+ it("starts OAuth through the TypeScript MCP client", async () => {
186
+ const result = await McpService.startOAuth({
187
+ id: "shttp-0",
188
+ type: "shttp",
189
+ name: "superhuman-mail",
190
+ url: "https://mcp.mail.superhuman.com/mcp",
191
+ auth: {
192
+ strategy: "oauth2",
193
+ authentication: {
194
+ type: "oauth",
195
+ client_auth_method: "none",
196
+ },
197
+ },
198
+ });
199
+
200
+ expect(result.job_id).toBe("job-1");
201
+ expect(startOAuth).toHaveBeenCalledTimes(1);
202
+ expect(startOAuth.mock.calls[0][0]).toMatchObject({
203
+ name: "superhuman-mail",
204
+ server: {
205
+ type: "http",
206
+ url: "https://mcp.mail.superhuman.com/mcp",
207
+ auth: {
208
+ strategy: "oauth2",
209
+ authentication: {
210
+ type: "oauth",
211
+ client_auth_method: "none",
212
+ },
213
+ },
214
+ },
215
+ timeout: 120,
216
+ });
217
+ expect(close).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it("submits OAuth callback through the TypeScript MCP client", async () => {
221
+ await McpService.submitOAuthCallback(
222
+ "job/1",
223
+ "http://localhost:1234/callback?code=abc",
224
+ );
225
+
226
+ expect(submitOAuthCallback).toHaveBeenCalledWith("job/1", {
227
+ callback_url: "http://localhost:1234/callback?code=abc",
228
+ });
229
+ expect(close).toHaveBeenCalledTimes(1);
230
+ });
231
+
232
+ it("gets OAuth status through the TypeScript MCP client", async () => {
233
+ await McpService.getOAuthStatus("job/1");
234
+
235
+ expect(getOAuthStatus).toHaveBeenCalledWith("job/1");
236
+ expect(close).toHaveBeenCalledTimes(1);
237
+ });
238
+ });
src/api/mcp-service/mcp-service.api.ts ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MCPClient } from "@openhands/typescript-client/clients";
2
+ import type { AgentServerMCPTestRequest } from "@openhands/typescript-client";
3
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
4
+ import {
5
+ getActiveBackend,
6
+ getRegisteredBackends,
7
+ } from "../backend-registry/active-store";
8
+ import {
9
+ getCredentialValidationForServer,
10
+ type CredentialValidation,
11
+ } from "#/utils/mcp-credential-validation";
12
+ import type {
13
+ ExtendedMCPTestResponse,
14
+ MCPOAuthStartResponse,
15
+ MCPOAuthStatusResponse,
16
+ MCPServerConfig,
17
+ } from "#/types/mcp-server";
18
+ import { redactMcpSecrets } from "#/utils/redact-mcp-secrets";
19
+ import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials";
20
+
21
+ const OAUTH_MCP_TEST_TIMEOUT_SECONDS = 120;
22
+
23
+ function toMcpServer(
24
+ server: MCPServerConfig,
25
+ ): AgentServerMCPTestRequest["server"] {
26
+ if (server.type === "stdio") {
27
+ return {
28
+ type: "stdio",
29
+ command: server.command!,
30
+ ...(server.args?.length && { args: server.args }),
31
+ ...(server.env &&
32
+ Object.keys(server.env).length > 0 && { env: server.env }),
33
+ };
34
+ }
35
+ return {
36
+ type: server.type === "sse" ? "sse" : "http",
37
+ url: server.url!,
38
+ ...(server.headers &&
39
+ Object.keys(server.headers).length > 0 && { headers: server.headers }),
40
+ ...(server.auth ? { auth: server.auth } : {}),
41
+ };
42
+ }
43
+
44
+ function getMcpTestTimeout(server: MCPServerConfig): number | undefined {
45
+ if (server.auth?.strategy !== "oauth2") return server.timeout;
46
+ return OAUTH_MCP_TEST_TIMEOUT_SECONDS;
47
+ }
48
+
49
+ async function buildMcpTestRequest(server: MCPServerConfig): Promise<{
50
+ request: AgentServerMCPTestRequest;
51
+ substituted: MCPServerConfig;
52
+ }> {
53
+ const validation = getCredentialValidationForServer(server);
54
+ const substituted = await substituteRedactedMcpCredentials(server);
55
+ const serverSpec = toMcpServer(substituted);
56
+ const timeout = getMcpTestTimeout(server);
57
+ return {
58
+ request: {
59
+ server: serverSpec,
60
+ ...(server.name ? { name: server.name } : {}),
61
+ ...(timeout !== undefined ? { timeout } : {}),
62
+ ...(validation ? { tool_call: validation.toolCall } : {}),
63
+ },
64
+ substituted,
65
+ };
66
+ }
67
+
68
+ function redactMcpTestResponse(
69
+ result: ExtendedMCPTestResponse,
70
+ redactionSources: (MCPServerConfig | undefined)[],
71
+ ): ExtendedMCPTestResponse {
72
+ if (!result.ok) {
73
+ return {
74
+ ...result,
75
+ error: redactMcpSecrets(result.error, ...redactionSources),
76
+ };
77
+ }
78
+ if (result.tool_result) {
79
+ return {
80
+ ...result,
81
+ tool_result: {
82
+ ...result.tool_result,
83
+ text: redactMcpSecrets(result.tool_result.text, ...redactionSources),
84
+ },
85
+ };
86
+ }
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Display-boundary post-processing shared by the test and OAuth probes:
92
+ * scrub secrets from error/tool-result text, then let the catalog entry's
93
+ * credential validation turn a failed read-only tool call into a
94
+ * `credentials` failure. The interpretation only runs when the probe tool
95
+ * is actually advertised in `tools` — a server variant that doesn't expose
96
+ * the tool (e.g. a hosted alternative to the stdio server the spec was
97
+ * written for) must degrade to a connectivity-only success, not be
98
+ * misreported as bad credentials.
99
+ */
100
+ function finalizeMcpTestResponse(
101
+ result: ExtendedMCPTestResponse,
102
+ validation: CredentialValidation | undefined,
103
+ redactionSources: (MCPServerConfig | undefined)[],
104
+ ): ExtendedMCPTestResponse {
105
+ const redacted = redactMcpTestResponse(result, redactionSources);
106
+ if (
107
+ redacted.ok &&
108
+ validation &&
109
+ redacted.tool_result &&
110
+ redacted.tools.includes(validation.toolCall.name)
111
+ ) {
112
+ const credentialError = validation.interpret(redacted.tool_result);
113
+ if (credentialError) {
114
+ return {
115
+ ok: false,
116
+ error: credentialError,
117
+ error_kind: "credentials",
118
+ };
119
+ }
120
+ }
121
+ return redacted;
122
+ }
123
+
124
+ function getMcpProbeOptions(): { host: string; apiKey?: string } {
125
+ const active = getActiveBackend().backend;
126
+ if (active.kind === "local") {
127
+ const { host, apiKey } = getAgentServerClientOptions();
128
+ return { host, ...(apiKey ? { apiKey } : {}) };
129
+ }
130
+
131
+ const localBackend = getRegisteredBackends().find(
132
+ (backend) => backend.kind === "local" && backend.host,
133
+ );
134
+ if (localBackend) {
135
+ return {
136
+ host: localBackend.host.replace(/\/+$/, ""),
137
+ ...(localBackend.apiKey ? { apiKey: localBackend.apiKey } : {}),
138
+ };
139
+ }
140
+ throw new Error("OAuth authorization requires a reachable local backend.");
141
+ }
142
+
143
+ function createMcpProbeClient(): MCPClient {
144
+ const { host, apiKey } = getMcpProbeOptions();
145
+ return new MCPClient({
146
+ host,
147
+ ...(apiKey ? { apiKey } : {}),
148
+ timeout: OAUTH_MCP_TEST_TIMEOUT_SECONDS * 1000 + 5000,
149
+ });
150
+ }
151
+
152
+ function oauthStatusToTestResponse(
153
+ status: MCPOAuthStatusResponse,
154
+ ): ExtendedMCPTestResponse {
155
+ if (status.status === "succeeded") {
156
+ return {
157
+ ok: true,
158
+ tools: status.tools ?? [],
159
+ ...(status.tool_result !== undefined && {
160
+ tool_result: status.tool_result,
161
+ }),
162
+ ...(status.oauth_state !== undefined && {
163
+ oauth_state: status.oauth_state,
164
+ }),
165
+ };
166
+ }
167
+ return {
168
+ ok: false,
169
+ error: status.error || "OAuth authorization did not complete",
170
+ error_kind: status.error_kind || "unknown",
171
+ };
172
+ }
173
+
174
+ const sleep = (ms: number) =>
175
+ new Promise<void>((resolve) => {
176
+ window.setTimeout(resolve, ms);
177
+ });
178
+
179
+ class McpService {
180
+ static async testServer(
181
+ server: MCPServerConfig,
182
+ ): Promise<ExtendedMCPTestResponse> {
183
+ // The MCP connectivity-test endpoint lives on the local agent-server. It
184
+ // spawns the configured stdio command / opens an SSE-or-SHTTP connection
185
+ // from that process's environment. Cloud backends don't expose this
186
+ // endpoint to the frontend — the MCP server would actually run inside the
187
+ // cloud sandbox, which isn't reachable from the browser before the user
188
+ // starts a conversation. Calling `getAgentServerClientOptions()` here for
189
+ // a cloud-active session would throw `NoBackendAvailableError("No backend
190
+ // is configured.")` and block the install flow entirely. Short-circuit
191
+ // with a synthetic success so saving proceeds; any real connection
192
+ // failure surfaces inside the conversation runtime instead.
193
+ if (getActiveBackend().backend.kind === "cloud") {
194
+ return { ok: true, tools: [] };
195
+ }
196
+ const validation = getCredentialValidationForServer(server);
197
+ const { host, apiKey } = getAgentServerClientOptions();
198
+ const client = new MCPClient({ host, ...(apiKey ? { apiKey } : {}) });
199
+ try {
200
+ const { request, substituted } = await buildMcpTestRequest(server);
201
+ const result = (await client.testServer(
202
+ request,
203
+ )) as ExtendedMCPTestResponse;
204
+ // Redact against both config forms: `server` may hold fresh plaintext
205
+ // input, `substituted` the stored (encrypted) round-trip values.
206
+ return finalizeMcpTestResponse(result, validation, [substituted, server]);
207
+ } finally {
208
+ client.close();
209
+ }
210
+ }
211
+
212
+ static async startOAuth(
213
+ server: MCPServerConfig,
214
+ ): Promise<MCPOAuthStartResponse> {
215
+ const client = createMcpProbeClient();
216
+ try {
217
+ return await McpService.startOAuthWithClient(client, server);
218
+ } finally {
219
+ client.close();
220
+ }
221
+ }
222
+
223
+ static async getOAuthStatus(jobId: string): Promise<MCPOAuthStatusResponse> {
224
+ const client = createMcpProbeClient();
225
+ try {
226
+ return await McpService.getOAuthStatusWithClient(client, jobId);
227
+ } finally {
228
+ client.close();
229
+ }
230
+ }
231
+
232
+ static async submitOAuthCallback(
233
+ jobId: string,
234
+ callbackUrl: string,
235
+ ): Promise<MCPOAuthStatusResponse> {
236
+ const client = createMcpProbeClient();
237
+ try {
238
+ return await McpService.submitOAuthCallbackWithClient(
239
+ client,
240
+ jobId,
241
+ callbackUrl,
242
+ );
243
+ } finally {
244
+ client.close();
245
+ }
246
+ }
247
+
248
+ static async authorizeOAuth(
249
+ server: MCPServerConfig,
250
+ ): Promise<ExtendedMCPTestResponse> {
251
+ const validation = getCredentialValidationForServer(server);
252
+ const finalize = (result: ExtendedMCPTestResponse) =>
253
+ finalizeMcpTestResponse(result, validation, [server]);
254
+ const popup = window.open("about:blank", "_blank");
255
+ const client = createMcpProbeClient();
256
+ try {
257
+ const start = await McpService.startOAuthWithClient(client, server);
258
+ if (!start.ok || !start.job_id || !start.authorization_url) {
259
+ popup?.close();
260
+ return finalize({
261
+ ok: false,
262
+ error: start.error || "Could not start OAuth authorization",
263
+ error_kind: start.error_kind || "unknown",
264
+ });
265
+ }
266
+
267
+ let status = await McpService.getOAuthStatusWithClient(
268
+ client,
269
+ start.job_id,
270
+ );
271
+ for (let attempt = 0; attempt < 20; attempt += 1) {
272
+ if (status.status === "succeeded" || status.status === "failed") {
273
+ popup?.close();
274
+ return finalize(oauthStatusToTestResponse(status));
275
+ }
276
+ if (status.callback_ready) break;
277
+ await sleep(250);
278
+ status = await McpService.getOAuthStatusWithClient(
279
+ client,
280
+ start.job_id,
281
+ );
282
+ }
283
+
284
+ if (popup) {
285
+ popup.location.href = start.authorization_url;
286
+ }
287
+
288
+ for (
289
+ let attempt = 0;
290
+ attempt < OAUTH_MCP_TEST_TIMEOUT_SECONDS;
291
+ attempt += 1
292
+ ) {
293
+ await sleep(1000);
294
+ status = await McpService.getOAuthStatusWithClient(
295
+ client,
296
+ start.job_id,
297
+ );
298
+ if (status.status === "succeeded" || status.status === "failed") {
299
+ popup?.close();
300
+ return finalize(oauthStatusToTestResponse(status));
301
+ }
302
+ }
303
+
304
+ return {
305
+ ok: false,
306
+ error: "OAuth authorization timed out",
307
+ error_kind: "timeout",
308
+ };
309
+ } finally {
310
+ client.close();
311
+ }
312
+ }
313
+
314
+ private static async startOAuthWithClient(
315
+ client: MCPClient,
316
+ server: MCPServerConfig,
317
+ ): Promise<MCPOAuthStartResponse> {
318
+ const { request } = await buildMcpTestRequest(server);
319
+ return client.startOAuth(request);
320
+ }
321
+
322
+ // typescript-client 1.36 types the OAuth probes with the generated
323
+ // agent-server models, which describe the opaque `oauth_state` JSON blobs as
324
+ // `unknown` rather than the recursive `MCPJsonValue` the local types use. The
325
+ // wire shape is unchanged, so narrow back to the app's boundary type.
326
+ private static async getOAuthStatusWithClient(
327
+ client: MCPClient,
328
+ jobId: string,
329
+ ): Promise<MCPOAuthStatusResponse> {
330
+ return (await client.getOAuthStatus(jobId)) as MCPOAuthStatusResponse;
331
+ }
332
+
333
+ private static async submitOAuthCallbackWithClient(
334
+ client: MCPClient,
335
+ jobId: string,
336
+ callbackUrl: string,
337
+ ): Promise<MCPOAuthStatusResponse> {
338
+ return (await client.submitOAuthCallback(jobId, {
339
+ callback_url: callbackUrl,
340
+ })) as MCPOAuthStatusResponse;
341
+ }
342
+ }
343
+
344
+ export default McpService;
src/api/option-service/option-service.api.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LLMMetadataClient } from "@openhands/typescript-client/clients";
2
+ import { loadAgentServerInfo } from "../agent-server-compatibility";
3
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
4
+ import { ModelsResponse, WebClientConfig } from "./option.types";
5
+
6
+ class OptionService {
7
+ static async getModels(): Promise<ModelsResponse> {
8
+ const llmClient = new LLMMetadataClient(getAgentServerClientOptions());
9
+ const [models, verifiedByProvider, providers] = await Promise.all([
10
+ llmClient.getModels(),
11
+ llmClient.getVerifiedModels(),
12
+ llmClient.getProviders(),
13
+ ]);
14
+
15
+ const verifiedProviders = Object.keys(verifiedByProvider ?? {}).sort();
16
+ const verifiedModels = verifiedProviders.flatMap(
17
+ (provider) => verifiedByProvider[provider] ?? [],
18
+ );
19
+
20
+ return {
21
+ models: models ?? [],
22
+ verified_models: verifiedModels,
23
+ verified_providers:
24
+ providers?.filter((provider) => verifiedProviders.includes(provider)) ??
25
+ verifiedProviders,
26
+ default_model: verifiedModels[0] ?? models?.[0] ?? "",
27
+ };
28
+ }
29
+
30
+ static async getConfig(): Promise<WebClientConfig> {
31
+ await loadAgentServerInfo();
32
+
33
+ return {
34
+ feature_flags: {
35
+ hide_llm_settings: false,
36
+ hide_users_page: true,
37
+ },
38
+ providers_configured: [],
39
+ maintenance_start_time: null,
40
+ recaptcha_site_key: null,
41
+ faulty_models: [],
42
+ error_message: null,
43
+ updated_at: new Date().toISOString(),
44
+ };
45
+ }
46
+ }
47
+
48
+ export default OptionService;
src/api/option-service/option.types.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Provider } from "#/types/settings";
2
+
3
+ /**
4
+ * Structured response from ``GET /api/options/models``.
5
+ *
6
+ * The backend is the single source of truth — the frontend no longer carries
7
+ * its own hardcoded verified-model lists.
8
+ */
9
+ export interface ModelsResponse {
10
+ /** Flat list of ``provider/model`` strings (bare names already prefixed). */
11
+ models: string[];
12
+ /** Model names (without provider) that OpenHands has verified to work well. */
13
+ verified_models: string[];
14
+ /** Provider names shown in the "Verified" section of the model selector. */
15
+ verified_providers: string[];
16
+ /** Recommended default model id (e.g. ``openhands/claude-opus-4-5-20251101``). */
17
+ default_model: string;
18
+ }
19
+
20
+ export interface WebClientFeatureFlags {
21
+ hide_llm_settings: boolean;
22
+ hide_users_page: boolean;
23
+ }
24
+
25
+ export interface WebClientConfig {
26
+ feature_flags: WebClientFeatureFlags;
27
+ providers_configured: Provider[];
28
+ maintenance_start_time: string | null;
29
+ recaptcha_site_key: string | null;
30
+ faulty_models: string[];
31
+ error_message: string | null;
32
+ updated_at: string;
33
+ }
src/api/profiles-service/profiles-service.api.ts ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ProfilesService is the single entry point for LLM-profile CRUD, routing per
3
+ * active backend so callers (hooks, the settings manager) stay backend-agnostic:
4
+ * - local agent-server: the SDK's ProfilesClient (`/api/profiles`), created
5
+ * per-call to pick up current backend configuration;
6
+ * - cloud app-server: `src/api/cloud/profiles-service.api.ts` (the org-gated
7
+ * `/api/organizations/{orgId}/profiles` routes, or the per-user settings
8
+ * route as a fallback) via the org-scoped cloud proxy.
9
+ * This mirrors how SettingsService branches to fetchCloudSettings().
10
+ *
11
+ * Uses ProfilesClient from @openhands/typescript-client v0.2.0+.
12
+ * All types are re-exported from the SDK for consumer convenience.
13
+ *
14
+ * Note: Unlike some SDK clients, we don't call client.close() here for
15
+ * consistency with other services (SettingsService, SecretsService) that
16
+ * also create SDK clients without explicit cleanup. The SDK clients use
17
+ * fetch-based HTTP which doesn't require connection cleanup.
18
+ */
19
+ import {
20
+ ProfilesClient,
21
+ type GetProfileOptions,
22
+ } from "@openhands/typescript-client/clients";
23
+ import type {
24
+ ProfileInfo as ClientProfileInfo,
25
+ ProfileListResponse as ClientProfileListResponse,
26
+ ProfileDetailResponse,
27
+ ProfileMutationResponse,
28
+ ActivateProfileResponse,
29
+ SaveProfileRequest,
30
+ ExposeSecretsMode,
31
+ ValidateProfileResponse,
32
+ } from "@openhands/typescript-client";
33
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
34
+ import { getActiveBackend } from "../backend-registry/active-store";
35
+ import {
36
+ activateCloudProfile,
37
+ deleteCloudProfile,
38
+ fetchCloudProfile,
39
+ fetchCloudProfiles,
40
+ renameCloudProfile,
41
+ saveCloudProfile,
42
+ } from "../cloud/profiles-service.api";
43
+
44
+ /**
45
+ * Profile summaries carry an optional `provider_connection_id` (the shared
46
+ * provider connection a profile links to), but `@openhands/typescript-client`
47
+ * predates that field. Widen the client types here so consumers can read it; it
48
+ * stays optional, so a client response without the field is still assignable.
49
+ */
50
+ export interface ProfileInfo extends ClientProfileInfo {
51
+ provider_connection_id?: string | null;
52
+ /** True when provider_connection_id is set but the referenced connection no longer exists. */
53
+ provider_connection_broken?: boolean;
54
+ }
55
+
56
+ export interface ProfileListResponse extends Omit<
57
+ ClientProfileListResponse,
58
+ "profiles"
59
+ > {
60
+ profiles: ProfileInfo[];
61
+ }
62
+
63
+ // Re-export SDK types for consumers
64
+ export type {
65
+ ProfileDetailResponse,
66
+ ProfileMutationResponse,
67
+ ActivateProfileResponse,
68
+ SaveProfileRequest,
69
+ ExposeSecretsMode,
70
+ ValidateProfileResponse,
71
+ };
72
+
73
+ function isCloudBackend(): boolean {
74
+ return getActiveBackend().backend.kind === "cloud";
75
+ }
76
+
77
+ function isAbortLike(error: unknown): boolean {
78
+ if (!error || typeof error !== "object") return false;
79
+ const name = "name" in error ? error.name : undefined;
80
+ if (name === "AbortError" || name === "TimeoutError") return true;
81
+ const cause = "cause" in error ? error.cause : undefined;
82
+ return (
83
+ !!cause &&
84
+ typeof cause === "object" &&
85
+ "name" in cause &&
86
+ (cause.name === "AbortError" || cause.name === "TimeoutError")
87
+ );
88
+ }
89
+
90
+ class ProfilesService {
91
+ static async listProfiles(): Promise<ProfileListResponse> {
92
+ if (isCloudBackend()) return fetchCloudProfiles();
93
+ return new ProfilesClient(getAgentServerClientOptions()).listProfiles();
94
+ }
95
+
96
+ static async getProfile(
97
+ name: string,
98
+ exposeSecrets?: ExposeSecretsMode,
99
+ ): Promise<ProfileDetailResponse> {
100
+ // Cloud never exposes profile secrets (api_key is always nulled with an
101
+ // api_key_set flag), so `exposeSecrets` is local-only.
102
+ if (isCloudBackend()) return fetchCloudProfile(name);
103
+ const options: GetProfileOptions = exposeSecrets ? { exposeSecrets } : {};
104
+ return new ProfilesClient(getAgentServerClientOptions()).getProfile(
105
+ name,
106
+ options,
107
+ );
108
+ }
109
+
110
+ static async saveProfile(
111
+ name: string,
112
+ request: SaveProfileRequest,
113
+ ): Promise<ProfileMutationResponse> {
114
+ if (isCloudBackend()) return saveCloudProfile(name, request);
115
+ return new ProfilesClient(getAgentServerClientOptions()).saveProfile(
116
+ name,
117
+ request,
118
+ );
119
+ }
120
+
121
+ static async deleteProfile(name: string): Promise<ProfileMutationResponse> {
122
+ if (isCloudBackend()) return deleteCloudProfile(name);
123
+ return new ProfilesClient(getAgentServerClientOptions()).deleteProfile(
124
+ name,
125
+ );
126
+ }
127
+
128
+ static async renameProfile(
129
+ name: string,
130
+ newName: string,
131
+ ): Promise<ProfileMutationResponse> {
132
+ if (isCloudBackend()) return renameCloudProfile(name, newName);
133
+ return new ProfilesClient(getAgentServerClientOptions()).renameProfile(
134
+ name,
135
+ newName,
136
+ );
137
+ }
138
+
139
+ static async activateProfile(name: string): Promise<ActivateProfileResponse> {
140
+ if (isCloudBackend()) return activateCloudProfile(name);
141
+ return new ProfilesClient(getAgentServerClientOptions()).activateProfile(
142
+ name,
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Pre-flight check: fire a minimal LLM completion to catch misconfigurations
148
+ * (invalid model names, missing provider prefixes, bad base URLs, invalid
149
+ * API keys) before a profile is saved.
150
+ *
151
+ * Returns `{ valid: true }` when the LLM responds, or
152
+ * `{ valid: false, error: { type, message } }` on a blocking error.
153
+ * Transient errors (rate limits, timeouts) are non-blocking.
154
+ *
155
+ * Cloud backends do not implement this endpoint; `null` signals
156
+ * "no verdict" so callers fall through to the normal save path.
157
+ */
158
+ static async validateProfile(
159
+ name: string,
160
+ request: SaveProfileRequest,
161
+ ): Promise<ValidateProfileResponse | null> {
162
+ if (isCloudBackend()) return null;
163
+ const client = new ProfilesClient({
164
+ ...getAgentServerClientOptions(),
165
+ timeout: 30000,
166
+ });
167
+ try {
168
+ return await client.validateProfile(name, request);
169
+ } catch (error) {
170
+ // Older agent-server versions don't have the endpoint → 404
171
+ // Treat as "no verdict" rather than blocking the save.
172
+ const status =
173
+ error && typeof error === "object" && "status" in error
174
+ ? (error as { status?: unknown }).status
175
+ : undefined;
176
+ if (
177
+ status === 404 ||
178
+ status === 429 ||
179
+ (typeof status === "number" && status >= 500) ||
180
+ isAbortLike(error)
181
+ ) {
182
+ return null;
183
+ }
184
+ throw error;
185
+ } finally {
186
+ client.close();
187
+ }
188
+ }
189
+ }
190
+
191
+ export default ProfilesService;
src/api/provider-connections-service/provider-connections-service.api.test.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ setActiveSelection,
4
+ setRegisteredBackends,
5
+ } from "#/api/backend-registry/active-store";
6
+ import type { Backend } from "#/api/backend-registry/types";
7
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
8
+ import ProviderConnectionsService from "./provider-connections-service.api";
9
+
10
+ const getMock = vi.hoisted(() => vi.fn());
11
+ const postMock = vi.hoisted(() => vi.fn());
12
+ const patchMock = vi.hoisted(() => vi.fn());
13
+ const deleteMock = vi.hoisted(() => vi.fn());
14
+ const closeMock = vi.hoisted(() => vi.fn());
15
+
16
+ vi.mock("@openhands/typescript-client/clients", () => ({
17
+ AgentServerClient: vi.fn(function AgentServerClientMock() {
18
+ return {
19
+ get: getMock,
20
+ post: postMock,
21
+ patch: patchMock,
22
+ delete: deleteMock,
23
+ close: closeMock,
24
+ };
25
+ }),
26
+ }));
27
+
28
+ const localBackend: Backend = {
29
+ id: "local-test",
30
+ name: "Local test backend",
31
+ host: "http://localhost:3000",
32
+ apiKey: "test-session-key",
33
+ kind: "local",
34
+ };
35
+
36
+ const connection = {
37
+ id: "conn-1",
38
+ display_name: "My OpenAI",
39
+ provider: "openai",
40
+ base_url: null,
41
+ created_at: 1,
42
+ updated_at: 2,
43
+ api_key_set: true,
44
+ };
45
+
46
+ const PATH = "/api/llm/provider-connections";
47
+
48
+ describe("ProviderConnectionsService", () => {
49
+ beforeEach(() => {
50
+ getMock.mockReset();
51
+ postMock.mockReset();
52
+ patchMock.mockReset();
53
+ deleteMock.mockReset();
54
+ closeMock.mockReset();
55
+ vi.mocked(AgentServerClient).mockClear();
56
+ setRegisteredBackends([localBackend]);
57
+ setActiveSelection({ backendId: localBackend.id });
58
+ });
59
+
60
+ afterEach(() => {
61
+ setActiveSelection(null);
62
+ setRegisteredBackends([]);
63
+ });
64
+
65
+ it("lists connections via the typed client and closes it", async () => {
66
+ getMock.mockResolvedValue([connection]);
67
+
68
+ const result = await ProviderConnectionsService.list();
69
+
70
+ expect(result).toEqual([connection]);
71
+ expect(vi.mocked(AgentServerClient)).toHaveBeenCalledWith({
72
+ host: "http://localhost:3000",
73
+ apiKey: "test-session-key",
74
+ });
75
+ expect(getMock).toHaveBeenCalledWith(PATH, { responseType: "json" });
76
+ expect(closeMock).toHaveBeenCalled();
77
+ });
78
+
79
+ it("creates a connection with the request body", async () => {
80
+ postMock.mockResolvedValue(connection);
81
+
82
+ const request = {
83
+ display_name: "My OpenAI",
84
+ provider: "openai",
85
+ api_key: "sk-123",
86
+ base_url: null,
87
+ };
88
+ const result = await ProviderConnectionsService.create(request);
89
+
90
+ expect(result).toEqual(connection);
91
+ expect(postMock).toHaveBeenCalledWith(PATH, request, {
92
+ responseType: "json",
93
+ });
94
+ expect(closeMock).toHaveBeenCalled();
95
+ });
96
+
97
+ it("updates a connection at the id-scoped path", async () => {
98
+ patchMock.mockResolvedValue(connection);
99
+
100
+ const result = await ProviderConnectionsService.update("conn-1", {
101
+ display_name: "Renamed",
102
+ });
103
+
104
+ expect(result).toEqual(connection);
105
+ expect(patchMock).toHaveBeenCalledWith(
106
+ `${PATH}/conn-1`,
107
+ { display_name: "Renamed" },
108
+ { responseType: "json" },
109
+ );
110
+ expect(closeMock).toHaveBeenCalled();
111
+ });
112
+
113
+ it("url-encodes the id when deleting", async () => {
114
+ deleteMock.mockResolvedValue(connection);
115
+
116
+ await ProviderConnectionsService.delete("a b/c");
117
+
118
+ expect(deleteMock).toHaveBeenCalledWith(`${PATH}/a%20b%2Fc`, {
119
+ responseType: "json",
120
+ });
121
+ expect(closeMock).toHaveBeenCalled();
122
+ });
123
+
124
+ it("closes the client even when the request throws", async () => {
125
+ getMock.mockRejectedValue(new Error("boom"));
126
+
127
+ await expect(ProviderConnectionsService.list()).rejects.toThrow("boom");
128
+ expect(closeMock).toHaveBeenCalled();
129
+ });
130
+ });
src/api/provider-connections-service/provider-connections-service.api.ts ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ProviderConnectionsService is the single entry point for provider-connection
3
+ * CRUD, routing per active backend so callers (hooks, the settings manager)
4
+ * stay backend-agnostic. A provider connection is a shared `api_key` + optional
5
+ * `base_url` that one or more LLM profiles reference by id; the backend resolves
6
+ * the credential into a runnable LLM at profile-load time, so this service only
7
+ * manages the stored connections.
8
+ *
9
+ * - local agent-server: the `/api/llm/provider-connections` CRUD endpoints via
10
+ * the generic `AgentServerClient` verb helpers (the same approach
11
+ * `LLMBalanceService` uses; there is no generated client for these routes in
12
+ * `@openhands/typescript-client` yet);
13
+ * - cloud app-server: `src/api/cloud/provider-connections-service.api.ts` (the
14
+ * org-gated `/api/organizations/{orgId}/provider-connections` routes) via the
15
+ * org-scoped cloud proxy.
16
+ *
17
+ * This mirrors how `ProfilesService` branches to the cloud profile service.
18
+ */
19
+ import { AgentServerClient } from "@openhands/typescript-client/clients";
20
+ import { getAgentServerClientOptions } from "../agent-server-client-options";
21
+ import { getActiveBackend } from "../backend-registry/active-store";
22
+ import {
23
+ fetchCloudProviderConnections,
24
+ createCloudProviderConnection,
25
+ updateCloudProviderConnection,
26
+ deleteCloudProviderConnection,
27
+ } from "../cloud/provider-connections-service.api";
28
+
29
+ const PROVIDER_CONNECTIONS_PATH = "/api/llm/provider-connections";
30
+
31
+ function isCloudBackend(): boolean {
32
+ return getActiveBackend().backend.kind === "cloud";
33
+ }
34
+
35
+ export interface ProviderConnection {
36
+ id: string;
37
+ display_name: string;
38
+ provider: string;
39
+ base_url: string | null;
40
+ created_at: number;
41
+ updated_at: number;
42
+ /** Whether the stored connection currently holds a usable key. */
43
+ api_key_set: boolean;
44
+ }
45
+
46
+ export interface CreateProviderConnectionRequest {
47
+ display_name: string;
48
+ provider: string;
49
+ api_key: string;
50
+ base_url?: string | null;
51
+ }
52
+
53
+ /**
54
+ * Partial update. Only the provided fields change. `api_key` may be sent to
55
+ * rotate the key; the agent-server rejects `api_key: null` (a connection must
56
+ * always keep a key), so callers omit it to leave the key unchanged.
57
+ */
58
+ export interface UpdateProviderConnectionRequest {
59
+ display_name?: string;
60
+ provider?: string;
61
+ api_key?: string;
62
+ base_url?: string | null;
63
+ }
64
+
65
+ function createClient(): AgentServerClient {
66
+ const { host, apiKey } = getAgentServerClientOptions();
67
+ return new AgentServerClient({ host, ...(apiKey ? { apiKey } : {}) });
68
+ }
69
+
70
+ class ProviderConnectionsService {
71
+ static async list(): Promise<ProviderConnection[]> {
72
+ if (isCloudBackend()) return fetchCloudProviderConnections();
73
+ const client = createClient();
74
+ try {
75
+ return await client.get<ProviderConnection[]>(PROVIDER_CONNECTIONS_PATH, {
76
+ responseType: "json",
77
+ });
78
+ } finally {
79
+ client.close();
80
+ }
81
+ }
82
+
83
+ static async create(
84
+ request: CreateProviderConnectionRequest,
85
+ ): Promise<ProviderConnection> {
86
+ if (isCloudBackend()) return createCloudProviderConnection(request);
87
+ const client = createClient();
88
+ try {
89
+ return await client.post<ProviderConnection>(
90
+ PROVIDER_CONNECTIONS_PATH,
91
+ request,
92
+ { responseType: "json" },
93
+ );
94
+ } finally {
95
+ client.close();
96
+ }
97
+ }
98
+
99
+ static async update(
100
+ id: string,
101
+ request: UpdateProviderConnectionRequest,
102
+ ): Promise<ProviderConnection> {
103
+ if (isCloudBackend()) return updateCloudProviderConnection(id, request);
104
+ const client = createClient();
105
+ try {
106
+ return await client.patch<ProviderConnection>(
107
+ `${PROVIDER_CONNECTIONS_PATH}/${encodeURIComponent(id)}`,
108
+ request,
109
+ { responseType: "json" },
110
+ );
111
+ } finally {
112
+ client.close();
113
+ }
114
+ }
115
+
116
+ static async delete(id: string): Promise<ProviderConnection> {
117
+ if (isCloudBackend()) return deleteCloudProviderConnection(id);
118
+ const client = createClient();
119
+ try {
120
+ return await client.delete<ProviderConnection>(
121
+ `${PROVIDER_CONNECTIONS_PATH}/${encodeURIComponent(id)}`,
122
+ { responseType: "json" },
123
+ );
124
+ } finally {
125
+ client.close();
126
+ }
127
+ }
128
+ }
129
+
130
+ export default ProviderConnectionsService;