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();
}