File size: 9,369 Bytes
63522a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | export const DEFAULT_WORKING_DIR = "workspace/project";
export type LockedCloudAuthMode = "api-key" | "cookie";
export interface AgentServerFormDefaults {
baseUrl: string;
sessionApiKey: string;
}
// Window-global key the static server injects `--lock-to-cloud` into; kept
// module-private because only `getLockedCloudHost()` reads it. The static
// server (`scripts/static-server.mjs`) and its tests reference the literal
// string directly, not this constant.
const LOCK_TO_CLOUD_WINDOW_KEY = "__AGENT_CANVAS_LOCK_TO_CLOUD__";
const LEGACY_CLOUD_DOMAIN = "all-hands.dev";
const CURRENT_CLOUD_DOMAIN = "openhands.dev";
const LEGACY_PRODUCTION_APP_HOST = `app.${LEGACY_CLOUD_DOMAIN}`;
const CURRENT_PRODUCTION_APP_HOST = CURRENT_CLOUD_DOMAIN;
const PRODUCTION_APP_HOST_ALIAS = `app.${CURRENT_CLOUD_DOMAIN}`;
function trimToNull(value?: string | null): string | null {
return value?.trim() || null;
}
function normalizeBaseUrl(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim().replace(/\/$/, "");
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (typeof window !== "undefined") {
return `${window.location.protocol}//${trimmed}`;
}
return `http://${trimmed}`;
}
function normalizeCloudHost(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return null;
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
return `https://${trimmed}`;
}
function canonicalizeCloudHostname(hostname: string): string {
const lower = hostname.toLowerCase();
if (
lower === LEGACY_PRODUCTION_APP_HOST ||
lower === PRODUCTION_APP_HOST_ALIAS
) {
return CURRENT_PRODUCTION_APP_HOST;
}
if (lower === LEGACY_CLOUD_DOMAIN) return CURRENT_CLOUD_DOMAIN;
if (lower.endsWith(`.${LEGACY_CLOUD_DOMAIN}`)) {
return `${lower.slice(0, -LEGACY_CLOUD_DOMAIN.length)}${CURRENT_CLOUD_DOMAIN}`;
}
return lower;
}
function getCloudHostComparisonKey(value?: string | null): string | null {
const normalized = normalizeCloudHost(value);
if (!normalized) return null;
try {
const url = new URL(normalized);
const port = url.port ? `:${url.port}` : "";
return `${url.protocol}//${canonicalizeCloudHostname(url.hostname)}${port}`;
} catch {
return normalized.toLowerCase();
}
}
export function getCookieAuthCloudHost(): string | null {
const lockedHost = getLockedCloudHost();
if (
!lockedHost ||
typeof window === "undefined" ||
!isSameCloudHost(window.location.origin, lockedHost)
) {
return null;
}
return window.location.origin;
}
function getConfiguredBaseUrl(): string | null {
return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL);
}
/**
* Return the session API key supplied by the deployment host.
*
* Two sources are consulted, in order:
* 1. `VITE_SESSION_API_KEY` — baked into the bundle at build time (used by
* `npm run dev` so the dev server has the key without a round-trip).
* 2. `window.__AGENT_CANVAS_SESSION_API_KEY__` — injected into `index.html`
* at serve time by `scripts/static-server.mjs --session-api-key <key>`.
* This is the path used by the published `agent-canvas` binary, where
* `VITE_SESSION_API_KEY` is empty in the prebuilt bundle and the
* runtime key is generated when the user launches the CLI.
*
* Without the window-global fallback, the published binary cannot construct a
* default local backend (`makeDefaultLocalBackend()` returns null), the
* registry is left empty, and the user sees the Manage Backends modal
* instead of the onboarding flow.
*/
export function getBakedSessionApiKey(): string | null {
const envKey = trimToNull(import.meta.env.VITE_SESSION_API_KEY);
if (envKey) return envKey;
if (typeof window !== "undefined") {
const injected = (window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_SESSION_API_KEY__;
if (typeof injected === "string") {
return trimToNull(injected);
}
}
return null;
}
export function getAgentServerFormDefaults(): AgentServerFormDefaults {
return {
baseUrl: getAgentServerBaseUrl() ?? "",
sessionApiKey: getAgentServerSessionApiKey() ?? "",
};
}
export function getLockedCloudHost(): string | null {
const envHost = normalizeCloudHost(import.meta.env.VITE_LOCK_TO_CLOUD);
if (envHost) return envHost;
if (typeof window !== "undefined") {
const injected = (window as unknown as Record<string, unknown>)[
LOCK_TO_CLOUD_WINDOW_KEY
];
if (typeof injected === "string") {
return normalizeCloudHost(injected);
}
}
return null;
}
/**
* Compare a backend host against the locked Cloud host, normalizing
* trailing slashes, protocol, and case so that e.g.
* `https://app.all-hands.dev/` matches `https://app.all-hands.dev`.
*
* Used by the locked-to-Cloud gates (`root.tsx`,
* `onboarding-modal.tsx`) to decide whether the active backend is the
* configured locked Cloud host — a Cloud backend on a *different* host
* (or a stale Local backend) must not be treated as the locked backend.
*/
export function isSameCloudHost(
host: string | null | undefined,
lockedHost: string | null | undefined,
): boolean {
const a = getCloudHostComparisonKey(host);
const b = getCloudHostComparisonKey(lockedHost);
if (!a || !b) return false;
return a === b;
}
export function getLockedCloudAuthMode(): LockedCloudAuthMode {
return getCookieAuthCloudHost() ? "cookie" : "api-key";
}
export function getAgentServerBaseUrl(): string | null {
const configuredUrl = getConfiguredBaseUrl();
if (configuredUrl) return configuredUrl;
if (typeof window !== "undefined") {
return window.location.origin;
}
return null;
}
export function getAgentServerSessionApiKey(): string | null {
return getBakedSessionApiKey();
}
export function getAgentServerWorkingDir(): string {
const envDir = import.meta.env.VITE_WORKING_DIR?.trim();
if (envDir) return envDir;
return DEFAULT_WORKING_DIR;
}
function buildWorkingDir(base: string, conversationId: string): string {
const trimmed = base.replace(/\/+$/, "");
const hex = conversationId.replace(/-/g, "");
return `${trimmed}/${hex}`;
}
export function buildConversationWorkingDir(conversationId: string): string {
return buildWorkingDir(getAgentServerWorkingDir(), conversationId);
}
/**
* Conversation working dir under the backend-relative default
* (`workspace/project/<hex>`), deliberately ignoring any baked absolute
* `VITE_WORKING_DIR`. `resolveAbsoluteAgentServerPath()` anchors this to the
* active backend's own home via `GET /api/file/home`, so it resolves to a
* writable path on whichever backend actually runs the conversation.
*/
export function buildRelativeConversationWorkingDir(
conversationId: string,
): string {
return buildWorkingDir(DEFAULT_WORKING_DIR, conversationId);
}
/**
* Whether `backendHost` is the same host that served this frontend.
*
* A launcher-baked `VITE_WORKING_DIR` is an absolute path on the serving
* host's filesystem, so it is only valid on that exact backend. The seeded
* `default-local` entry starts life pointing at the served origin, but its
* `host` is mutable — the user can edit it to a remote backend while its id
* stays `default-local`. Matching on the host (not the stable id) keeps the
* real invariant: the baked path is only safe for the host it was baked for.
*/
export function isServedOriginHost(
backendHost: string | null | undefined,
): boolean {
const served = normalizeBaseUrl(getAgentServerBaseUrl());
const candidate = normalizeBaseUrl(backendHost);
if (!served || !candidate) return false;
return served === candidate;
}
/**
* Base working dir for a new conversation on the backend at `backendHost`.
* The baked (possibly absolute) default is used only for the served-origin
* backend; every other backend gets the relative default, anchored to its
* own home by `resolveAbsoluteAgentServerPath()`.
*/
export function buildConversationWorkingDirForBackend(
conversationId: string,
backendHost: string | null | undefined,
): string {
return isServedOriginHost(backendHost)
? buildConversationWorkingDir(conversationId)
: buildRelativeConversationWorkingDir(conversationId);
}
/**
* Workspace root for `backendHost` — the dir each conversation's `<root>/<hex>`
* working dir is created under. Same per-backend rule as
* `buildConversationWorkingDirForBackend()`.
*/
export function getWorkspaceRootForBackend(
backendHost: string | null | undefined,
): string {
return isServedOriginHost(backendHost)
? getAgentServerWorkingDir()
: DEFAULT_WORKING_DIR;
}
export function getAgentServerHeaders(): Record<string, string> {
const sessionApiKey = getAgentServerSessionApiKey();
return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {};
}
export function isAuthRequired(): boolean {
return (
import.meta.env.VITE_AUTH_REQUIRED === "true" ||
(typeof window !== "undefined" &&
(window as unknown as Record<string, unknown>)
.__AGENT_CANVAS_AUTH_REQUIRED__ === true)
);
}
export function isAuthRequiredAndMissing(): boolean {
if (!isAuthRequired()) return false;
return !getAgentServerSessionApiKey();
}
|