Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/api/acp-service/acp-service.api.ts +110 -0
- src/api/agent-profiles-service/agent-profiles-service.api.ts +130 -0
- src/api/agent-profiles-service/profile-field-support.ts +41 -0
- src/api/automation-service/automation-service.api.test.ts +458 -0
- src/api/automation-service/automation-service.api.ts +794 -0
- src/api/backend-registry/active-store.ts +193 -0
- src/api/backend-registry/auth.ts +18 -0
- src/api/backend-registry/default-backend.ts +70 -0
- src/api/backend-registry/health-storage.ts +82 -0
- src/api/backend-registry/health-store.ts +99 -0
- src/api/backend-registry/last-conversation-store.ts +76 -0
- src/api/backend-registry/storage.ts +244 -0
- src/api/backend-registry/types.ts +23 -0
- src/api/backend-registry/url-selection.ts +83 -0
- src/api/bash-service/bash-service.api.ts +121 -0
- src/api/cloud/agent-profiles-service.api.ts +98 -0
- src/api/cloud/client.ts +63 -0
- src/api/cloud/conversation-service.api.ts +336 -0
- src/api/cloud/git-service.api.ts +104 -0
- src/api/cloud/organization-service.api.ts +147 -0
- src/api/cloud/profiles-service.api.ts +140 -0
- src/api/cloud/provider-connections-service.api.ts +99 -0
- src/api/cloud/proxy.ts +39 -0
- src/api/cloud/sandbox-service.api.ts +39 -0
- src/api/cloud/sandbox-service.types.ts +21 -0
- src/api/cloud/secrets-service.api.ts +118 -0
- src/api/cloud/settings-service.api.ts +230 -0
- src/api/cloud/skills-service.api.ts +83 -0
- src/api/cloud/types.ts +56 -0
- src/api/config-service/config-service.api.ts +195 -0
- src/api/config-service/config-service.types.ts +50 -0
- src/api/conversation-service/agent-server-conversation-service.api.ts +1145 -0
- src/api/conversation-service/agent-server-conversation-service.types.ts +298 -0
- src/api/conversation-service/conversation-service.api.ts +77 -0
- src/api/event-service/event-service.api.test.ts +409 -0
- src/api/event-service/event-service.api.ts +184 -0
- src/api/event-service/event-service.types.ts +34 -0
- src/api/git-service/agent-server-git-service.api.ts +282 -0
- src/api/git-service/git-service.api.ts +178 -0
- src/api/mcp-health/mcp-health-store.ts +79 -0
- src/api/mcp-health/probe-mcp-server-health.ts +135 -0
- src/api/mcp-service/mcp-redacted-credentials.test.ts +308 -0
- src/api/mcp-service/mcp-redacted-credentials.ts +138 -0
- src/api/mcp-service/mcp-service.api.test.ts +238 -0
- src/api/mcp-service/mcp-service.api.ts +344 -0
- src/api/option-service/option-service.api.ts +48 -0
- src/api/option-service/option.types.ts +33 -0
- src/api/profiles-service/profiles-service.api.ts +191 -0
- src/api/provider-connections-service/provider-connections-service.api.test.ts +130 -0
- 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×tamp__gte=2026-07-12T00%3A00%3A00.000Z×tamp__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×tamp__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;
|