From 005ea2dd04d062529e0006dc650fb44281fc9b2f Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 20 May 2026 17:40:59 +0000 Subject: [PATCH] Unify local-backend API key under LOCAL_BACKEND_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the same root cause as #680 / #681 — the agent-server and the automation sidecar used two separate API keys with two matching VITE_* values baked into the frontend: - SESSION_API_KEY / OH_SESSION_API_KEYS_0 -> VITE_SESSION_API_KEY - AUTOMATION_LOCAL_API_KEY -> VITE_AUTOMATION_API_KEY Each had its own persisted file under ~/.openhands/agent-canvas, and the two halves could (and routinely did) drift, so `/api/automation/v1` 401'd while the agent-server worked, or vice versa. Collapse both into a single key end-to-end: LOCAL_BACKEND_API_KEY (env, dev launcher) -> persisted to ~/.openhands/agent-canvas/local-backend-api-key.txt (with a one-shot migration from the legacy session-api-key.txt so existing users don't get a fresh key on first run) -> fed to agent-server as OH_SESSION_API_KEYS_0 -> fed to automation backend as AUTOMATION_LOCAL_API_KEY and AUTOMATION_AGENT_SERVER_API_KEY -> baked into the frontend as VITE_LOCAL_BACKEND_API_KEY for the `npm run dev` bootstrap Frontend now reads the active local backend's stored `apiKey` as the source of truth for automation calls (with VITE_LOCAL_BACKEND_API_KEY only as the dev-mode bootstrap), so the Docker case works once the user enters their session key in Settings — no separate credential needed on the frontend. Docker `entrypoint.sh` defaults OPENHANDS_AUTOMATION_API_KEY, AUTOMATION_LOCAL_API_KEY and AUTOMATION_AGENT_SERVER_API_KEY to the generated session key so the prod container is no longer 401-by-default on /api/automation/* either. Explicit env overrides still win. The agent-server still receives OH_SESSION_API_KEYS_0 and the automation backend still receives AUTOMATION_LOCAL_API_KEY because those are the env var names those backends own. The wire-level `X-Session-API-Key` header and `Bearer` Authorization scheme are unchanged. Co-authored-by: openhands --- __tests__/api/agent-server-config.test.ts | 4 +- .../api/backend-registry/storage.test.ts | 6 +- .../to-app-conversation-session-key.test.ts | 4 +- __tests__/scripts/dev-extra-backend.test.ts | 14 +- __tests__/scripts/dev-safe.test.ts | 100 +++++++++----- __tests__/scripts/dev-static.test.ts | 9 +- __tests__/scripts/dev-with-automation.test.ts | 123 ++++-------------- docker/entrypoint.sh | 16 ++- playwright.live.config.ts | 7 +- scripts/dev-extra-backend.mjs | 2 +- scripts/dev-safe.mjs | 122 +++++++++-------- scripts/dev-static.mjs | 4 +- scripts/dev-with-automation.mjs | 91 ++++++------- scripts/static-build.mjs | 11 +- src/api/agent-server-config.ts | 2 +- .../automation-service.api.ts | 19 ++- src/api/backend-registry/default-backend.ts | 2 +- src/api/backend-registry/storage.ts | 9 +- .../sdk-settings/sdk-section-page.tsx | 2 +- .../live/utils/agent-server-conversation.ts | 5 +- 20 files changed, 263 insertions(+), 289 deletions(-) diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts index d2fdd6b28..b556d3e47 100644 --- a/__tests__/api/agent-server-config.test.ts +++ b/__tests__/api/agent-server-config.test.ts @@ -52,7 +52,7 @@ describe("agent server config", () => { it("prefills the settings form from environment defaults when local settings are empty", () => { vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com/"); - vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key"); + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "env-session-key"); expect(getAgentServerFormDefaults()).toEqual({ baseUrl: "https://env-agent.example.com", @@ -75,7 +75,7 @@ describe("agent server config", () => { it("lets saved interface settings override environment defaults", () => { vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com"); - vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key"); + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "env-session-key"); saveAgentServerConfig({ baseUrl: "https://saved-agent.example.com/", diff --git a/__tests__/api/backend-registry/storage.test.ts b/__tests__/api/backend-registry/storage.test.ts index 25aa64377..6701ab565 100644 --- a/__tests__/api/backend-registry/storage.test.ts +++ b/__tests__/api/backend-registry/storage.test.ts @@ -93,7 +93,7 @@ describe("backend-registry storage", () => { }); it("fills a missing API key on the default Local backend from env defaults", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "fresh-session-key"); window.localStorage.setItem( BACKENDS_STORAGE_KEY, JSON.stringify([ @@ -123,7 +123,7 @@ describe("backend-registry storage", () => { it("refreshes a stale API key on the default Local backend from env defaults", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "fresh-session-key"); window.localStorage.setItem( BACKENDS_STORAGE_KEY, JSON.stringify([ @@ -152,7 +152,7 @@ describe("backend-registry storage", () => { }); it("does not fill the default Local backend API key after its host is edited", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "fresh-session-key"); window.localStorage.setItem( BACKENDS_STORAGE_KEY, JSON.stringify([ diff --git a/__tests__/api/to-app-conversation-session-key.test.ts b/__tests__/api/to-app-conversation-session-key.test.ts index d52c27057..373c5874e 100644 --- a/__tests__/api/to-app-conversation-session-key.test.ts +++ b/__tests__/api/to-app-conversation-session-key.test.ts @@ -20,8 +20,8 @@ afterEach(() => { }); describe("toAppConversation session_api_key hydration", () => { - it("prefers the configured VITE_SESSION_API_KEY over a stale stored default-local apiKey", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); + it("prefers the configured VITE_LOCAL_BACKEND_API_KEY over a stale stored default-local apiKey", () => { + vi.stubEnv("VITE_LOCAL_BACKEND_API_KEY", "fresh-session-key"); setRegisteredBackends([ { diff --git a/__tests__/scripts/dev-extra-backend.test.ts b/__tests__/scripts/dev-extra-backend.test.ts index f7a0cfa48..8a79351f5 100644 --- a/__tests__/scripts/dev-extra-backend.test.ts +++ b/__tests__/scripts/dev-extra-backend.test.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { buildExtraBackendConfig } from "../../scripts/dev-extra-backend.mjs"; import { buildSafeDevConfig, - resetPersistedSessionApiKeyCache, + resetPersistedApiKeyCache, } from "../../scripts/dev-safe.mjs"; const repoRoot = path.resolve( @@ -29,17 +29,17 @@ describe("buildExtraBackendConfig", () => { const dir = keyDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); }); function isolatedKeyPath(): string { const dir = mkdtempSync(path.join(tmpdir(), "extra-backend-key-")); keyDirs.push(dir); - return path.join(dir, "session-api-key.txt"); + return path.join(dir, "local-backend-api-key.txt"); } it("defaults to ports 18002/18003 distinct from the bundled instance", () => { - const env = { OH_SESSION_API_KEY_PATH: isolatedKeyPath() }; + const env = { OH_LOCAL_BACKEND_API_KEY_PATH: isolatedKeyPath() }; const bundled = buildSafeDevConfig(repoRoot, env); const extra = buildExtraBackendConfig(repoRoot, env); @@ -55,7 +55,7 @@ describe("buildExtraBackendConfig", () => { const config = buildExtraBackendConfig(repoRoot, { OH_CANVAS_EXTRA_BACKEND_PORT: "29000", OH_CANVAS_EXTRA_VSCODE_PORT: "29001", - OH_SESSION_API_KEY_PATH: isolatedKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: isolatedKeyPath(), }); expect(config.backendPort).toBe(29000); @@ -66,7 +66,7 @@ describe("buildExtraBackendConfig", () => { it("shares state dir, conversations, bash events, and secret key with the bundled config", () => { const env = { OH_CANVAS_SAFE_STATE_DIR: "/tmp/canvas-state", - OH_SESSION_API_KEY_PATH: isolatedKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: isolatedKeyPath(), }; const bundled = buildSafeDevConfig(repoRoot, env); const extra = buildExtraBackendConfig(repoRoot, env); @@ -82,7 +82,7 @@ describe("buildExtraBackendConfig", () => { expect(() => buildExtraBackendConfig(repoRoot, { OH_CANVAS_EXTRA_BACKEND_PORT: "not-a-port", - OH_SESSION_API_KEY_PATH: isolatedKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: isolatedKeyPath(), }), ).toThrow(/Invalid port/); }); diff --git a/__tests__/scripts/dev-safe.test.ts b/__tests__/scripts/dev-safe.test.ts index f78b44fd5..d8ad3e056 100644 --- a/__tests__/scripts/dev-safe.test.ts +++ b/__tests__/scripts/dev-safe.test.ts @@ -27,10 +27,11 @@ import { validateLocalAgentServerPath, findFreePort, findFreePorts, - getOrCreatePersistedSessionApiKey, - resetPersistedSessionApiKeyCache, + getOrCreatePersistedLocalBackendApiKey, + resetPersistedApiKeyCache, } from "../../scripts/dev-safe.mjs"; import { + existsSync, mkdtempSync, mkdirSync, readFileSync, @@ -183,17 +184,17 @@ describe("buildSafeDevConfigAsync", () => { rmSync(keyTmp, { recursive: true, force: true }); keyTmp = null; } - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); }); function tempKeyPath(): string { keyTmp = mkdtempSync(path.join(tmpdir(), "dev-safe-async-key-")); - return path.join(keyTmp, "session-api-key.txt"); + return path.join(keyTmp, "local-backend-api-key.txt"); } it("returns config with dynamically allocated ports", async () => { const config = await buildSafeDevConfigAsync(repoRoot, { - OH_SESSION_API_KEY_PATH: tempKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: tempKeyPath(), }); expect(typeof config.backendPort).toBe("number"); @@ -219,7 +220,7 @@ describe("buildSafeDevConfigAsync", () => { // Request the busy port via env var const config = await buildSafeDevConfigAsync(repoRoot, { OH_CANVAS_SAFE_BACKEND_PORT: busyPort.toString(), - OH_SESSION_API_KEY_PATH: tempKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: tempKeyPath(), }); // Backend port should NOT be busyPort since it's taken @@ -494,19 +495,19 @@ describe("buildSafeDevConfig", () => { rmSync(keyTmp, { recursive: true, force: true }); keyTmp = null; } - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); }); function tempKeyPath(): string { keyTmp = mkdtempSync(path.join(tmpdir(), "dev-safe-key-")); - return path.join(keyTmp, "session-api-key.txt"); + return path.join(keyTmp, "local-backend-api-key.txt"); } it("builds isolated default paths and ports", () => { const cwd = "/workspace/project/agent-canvas"; const config = buildSafeDevConfig(cwd, { - OH_SESSION_API_KEY_PATH: tempKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: tempKeyPath(), }); expect(config.backendPort).toBe(18000); @@ -539,7 +540,7 @@ describe("buildSafeDevConfig", () => { OH_CANVAS_SAFE_VSCODE_PORT: "19010", OH_CANVAS_SAFE_STATE_DIR: ".tmp/dev-safe", VITE_WORKING_DIR: "/workspace/custom-repo", - OH_SESSION_API_KEY_PATH: tempKeyPath(), + OH_LOCAL_BACKEND_API_KEY_PATH: tempKeyPath(), }); expect(config.backendPort).toBe(19000); @@ -550,53 +551,53 @@ describe("buildSafeDevConfig", () => { expect(config.workingDir).toBe("/workspace/custom-repo"); }); - it("falls back to the persisted session key file when no env override is set", () => { + it("falls back to the persisted local-backend key file when no env override is set", () => { const keyPath = tempKeyPath(); const config = buildSafeDevConfig("/workspace/project/agent-canvas", { - OH_SESSION_API_KEY_PATH: keyPath, + OH_LOCAL_BACKEND_API_KEY_PATH: keyPath, }); // A fresh hex key was generated and persisted. - expect(config.sessionApiKey).toMatch(/^[a-f0-9]{64}$/); - expect(readFileSync(keyPath, "utf8").trim()).toBe(config.sessionApiKey); + expect(config.localBackendApiKey).toMatch(/^[a-f0-9]{64}$/); + expect(readFileSync(keyPath, "utf8").trim()).toBe(config.localBackendApiKey); }); it("reuses the same key across config builds, simulating restarts", () => { const keyPath = tempKeyPath(); const first = buildSafeDevConfig("/workspace/project/agent-canvas", { - OH_SESSION_API_KEY_PATH: keyPath, + OH_LOCAL_BACKEND_API_KEY_PATH: keyPath, }); // Simulate a fresh process by clearing the in-memory cache; the file // on disk is what should make the key stable. - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); const second = buildSafeDevConfig("/workspace/project/agent-canvas", { - OH_SESSION_API_KEY_PATH: keyPath, + OH_LOCAL_BACKEND_API_KEY_PATH: keyPath, }); - expect(second.sessionApiKey).toBe(first.sessionApiKey); + expect(second.localBackendApiKey).toBe(first.localBackendApiKey); }); - it("env-provided session keys take precedence over the persisted file", () => { + it("LOCAL_BACKEND_API_KEY takes precedence over the persisted file", () => { const keyPath = tempKeyPath(); // Pre-seed the file with one key. mkdirSync(path.dirname(keyPath), { recursive: true }); writeFileSync(keyPath, "persisted-key-value\n"); const config = buildSafeDevConfig("/workspace/project/agent-canvas", { - SESSION_API_KEY: "env-key-wins", - OH_SESSION_API_KEY_PATH: keyPath, + LOCAL_BACKEND_API_KEY: "env-key-wins", + OH_LOCAL_BACKEND_API_KEY_PATH: keyPath, }); - expect(config.sessionApiKey).toBe("env-key-wins"); + expect(config.localBackendApiKey).toBe("env-key-wins"); // The file is left untouched. expect(readFileSync(keyPath, "utf8").trim()).toBe("persisted-key-value"); }); }); -describe("getOrCreatePersistedSessionApiKey", () => { +describe("getOrCreatePersistedLocalBackendApiKey", () => { let dir: string | null = null; afterEach(() => { @@ -604,17 +605,17 @@ describe("getOrCreatePersistedSessionApiKey", () => { rmSync(dir, { recursive: true, force: true }); dir = null; } - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); }); function tempPath(): string { - dir = mkdtempSync(path.join(tmpdir(), "session-key-")); - return path.join(dir, "nested", "session-api-key.txt"); + dir = mkdtempSync(path.join(tmpdir(), "local-backend-key-")); + return path.join(dir, "nested", "local-backend-api-key.txt"); } it("creates the file (and parent dirs) with a hex key on first call", () => { const filePath = tempPath(); - const key = getOrCreatePersistedSessionApiKey(filePath); + const key = getOrCreatePersistedLocalBackendApiKey(filePath); expect(key).toMatch(/^[a-f0-9]{64}$/); expect(readFileSync(filePath, "utf8").trim()).toBe(key); @@ -622,11 +623,11 @@ describe("getOrCreatePersistedSessionApiKey", () => { it("returns the existing key on subsequent calls (after cache reset)", () => { const filePath = tempPath(); - const first = getOrCreatePersistedSessionApiKey(filePath); + const first = getOrCreatePersistedLocalBackendApiKey(filePath); - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); - const second = getOrCreatePersistedSessionApiKey(filePath); + const second = getOrCreatePersistedLocalBackendApiKey(filePath); expect(second).toBe(first); }); @@ -635,7 +636,7 @@ describe("getOrCreatePersistedSessionApiKey", () => { mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, " abcdef1234 \n"); - const key = getOrCreatePersistedSessionApiKey(filePath); + const key = getOrCreatePersistedLocalBackendApiKey(filePath); expect(key).toBe("abcdef1234"); }); @@ -644,10 +645,45 @@ describe("getOrCreatePersistedSessionApiKey", () => { mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync(filePath, " \n"); - const key = getOrCreatePersistedSessionApiKey(filePath); + const key = getOrCreatePersistedLocalBackendApiKey(filePath); expect(key).toMatch(/^[a-f0-9]{64}$/); expect(readFileSync(filePath, "utf8").trim()).toBe(key); }); + + it("migrates a legacy sibling session-api-key.txt on first run", () => { + const filePath = tempPath(); + const legacyPath = path.join( + path.dirname(filePath), + "session-api-key.txt", + ); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(legacyPath, "legacy-session-key\n"); + + const key = getOrCreatePersistedLocalBackendApiKey(filePath); + + expect(key).toBe("legacy-session-key"); + expect(readFileSync(filePath, "utf8").trim()).toBe("legacy-session-key"); + // The legacy file has been renamed (not copied) so a second run sees + // only the new file. + expect(existsSync(legacyPath)).toBe(false); + }); + + it("does not migrate when the target file already exists", () => { + const filePath = tempPath(); + const legacyPath = path.join( + path.dirname(filePath), + "session-api-key.txt", + ); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(legacyPath, "legacy-session-key\n"); + writeFileSync(filePath, "current-key\n"); + + const key = getOrCreatePersistedLocalBackendApiKey(filePath); + + expect(key).toBe("current-key"); + // Legacy file is left untouched. + expect(readFileSync(legacyPath, "utf8").trim()).toBe("legacy-session-key"); + }); }); describe("buildNpmScriptCommand", () => { diff --git a/__tests__/scripts/dev-static.test.ts b/__tests__/scripts/dev-static.test.ts index 2048e2cc4..88bbd0be7 100644 --- a/__tests__/scripts/dev-static.test.ts +++ b/__tests__/scripts/dev-static.test.ts @@ -3,19 +3,18 @@ import { describe, expect, it } from "vitest"; import { buildAutomationBackendEnv } from "../../scripts/dev-static.mjs"; describe("dev-static", () => { - it("passes the agent-server session key to the automation backend", () => { + it("uses the unified local-backend key for both automation auth and the agent-server callback", () => { const env = buildAutomationBackendEnv({ agentServerPort: 18000, ingressPort: 8000, - localApiKey: "automation-local-key", - sessionApiKey: "agent-session-key", + localBackendApiKey: "local-backend-key", stateDir: "/tmp/agent-canvas-state", }); expect(env).toMatchObject({ AUTOMATION_AGENT_SERVER_URL: "http://localhost:18000", - AUTOMATION_AGENT_SERVER_API_KEY: "agent-session-key", - AUTOMATION_LOCAL_API_KEY: "automation-local-key", + AUTOMATION_AGENT_SERVER_API_KEY: "local-backend-key", + AUTOMATION_LOCAL_API_KEY: "local-backend-key", }); }); }); diff --git a/__tests__/scripts/dev-with-automation.test.ts b/__tests__/scripts/dev-with-automation.test.ts index 4cbc3853c..25ae6420d 100644 --- a/__tests__/scripts/dev-with-automation.test.ts +++ b/__tests__/scripts/dev-with-automation.test.ts @@ -24,7 +24,7 @@ import { DEFAULT_BACKEND_PORT, DEFAULT_AUTOMATION_PORT, } from "../../scripts/dev-with-automation.mjs"; -import { resetPersistedSessionApiKeyCache } from "../../scripts/dev-safe.mjs"; +import { resetPersistedApiKeyCache } from "../../scripts/dev-safe.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -117,11 +117,13 @@ describe("buildAutomationCommand", () => { }); describe("buildAgentServerAutomationEnv", () => { - it("exposes the local automation API key under the name agents use in curl commands", () => { + it("exposes the local-backend API key under the name agents use in curl commands", () => { expect( - buildAgentServerAutomationEnv({ localApiKey: "automation-local-key" }), + buildAgentServerAutomationEnv({ + localBackendApiKey: "local-backend-key", + }), ).toEqual({ - OPENHANDS_AUTOMATION_API_KEY: "automation-local-key", + OPENHANDS_AUTOMATION_API_KEY: "local-backend-key", }); }); }); @@ -139,12 +141,13 @@ describe("buildConfig", () => { const dir = keyDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); }); /** - * Build an env that points persisted dev API key files at a fresh temp dir, - * so tests don't write to the user's real ~/.openhands/agent-canvas files. + * Build an env that points the persisted local-backend API key file at a + * fresh temp dir, so tests don't write to the user's real + * ~/.openhands/agent-canvas files. */ function envWithIsolatedKeyPath( extra: Record = {}, @@ -152,8 +155,10 @@ describe("buildConfig", () => { const dir = mkdtempSync(path.join(tmpdir(), "buildconfig-key-")); keyDirs.push(dir); return { - OH_SESSION_API_KEY_PATH: path.join(dir, "session-api-key.txt"), - OH_AUTOMATION_API_KEY_PATH: path.join(dir, "automation-api-key.txt"), + OH_LOCAL_BACKEND_API_KEY_PATH: path.join( + dir, + "local-backend-api-key.txt", + ), ...extra, }; } @@ -288,115 +293,33 @@ describe("buildConfig", () => { expect(config.verbose).toBe(true); }); - it("uses a persisted generated local automation API key by default", async () => { + it("generates and persists a default local-backend API key", async () => { const config = await buildConfig({}, envWithIsolatedKeyPath()); // Default is a 64-char hex string (256-bit random key) - expect(config.localApiKey).toMatch(/^[0-9a-f]{64}$/); + expect(config.localBackendApiKey).toMatch(/^[0-9a-f]{64}$/); }); - it("reuses the persisted local automation API key across restarts", async () => { + it("reuses the persisted local-backend API key across restarts", async () => { const env = envWithIsolatedKeyPath(); const first = await buildConfig({}, env); // Simulate a fresh process invocation (the file on disk should be // what makes the key stable). - resetPersistedSessionApiKeyCache(); + resetPersistedApiKeyCache(); const second = await buildConfig({}, env); - expect(second.localApiKey).toBe(first.localApiKey); + expect(second.localBackendApiKey).toBe(first.localBackendApiKey); }); - it("respects custom AUTOMATION_LOCAL_API_KEY from env", async () => { + it("respects custom LOCAL_BACKEND_API_KEY from env", async () => { const config = await buildConfig( {}, - envWithIsolatedKeyPath({ AUTOMATION_LOCAL_API_KEY: "my-custom-key" }), + envWithIsolatedKeyPath({ LOCAL_BACKEND_API_KEY: "my-custom-key" }), ); - expect(config.localApiKey).toBe("my-custom-key"); - }); - - it("falls back to a freshly persisted session API key by default", async () => { - const config = await buildConfig({}, envWithIsolatedKeyPath()); - - // Default is a 64-char hex string (256-bit random key) read from / - // written to OH_SESSION_API_KEY_PATH. - expect(config.sessionApiKey).toMatch(/^[0-9a-f]{64}$/); - }); - - it("reuses the persisted session API key across calls (stable across restarts)", async () => { - const env = envWithIsolatedKeyPath(); - const first = await buildConfig({}, env); - - // Simulate a fresh process invocation (the file on disk should be - // what makes the key stable). - resetPersistedSessionApiKeyCache(); - - const second = await buildConfig({}, env); - - expect(second.sessionApiKey).toBe(first.sessionApiKey); - }); - - it("reads sessionApiKey from SESSION_API_KEY", async () => { - const config = await buildConfig({}, { SESSION_API_KEY: "my-session-key" }); - - expect(config.sessionApiKey).toBe("my-session-key"); - }); - - it("reads sessionApiKey from VITE_SESSION_API_KEY as fallback", async () => { - const config = await buildConfig( - {}, - { VITE_SESSION_API_KEY: "vite-session-key" }, - ); - - expect(config.sessionApiKey).toBe("vite-session-key"); - }); - - it("SESSION_API_KEY takes precedence over VITE_SESSION_API_KEY", async () => { - const config = await buildConfig( - {}, - { - SESSION_API_KEY: "session-key", - VITE_SESSION_API_KEY: "vite-key", - }, - ); - - expect(config.sessionApiKey).toBe("session-key"); - }); - - it("reads sessionApiKey from OH_SESSION_API_KEYS_0 (agent-server V1 env)", async () => { - const config = await buildConfig( - {}, - { OH_SESSION_API_KEYS_0: "v1-session-key" }, - ); - - expect(config.sessionApiKey).toBe("v1-session-key"); - }); - - it("SESSION_API_KEY takes precedence over OH_SESSION_API_KEYS_0", async () => { - const config = await buildConfig( - {}, - { - SESSION_API_KEY: "v0-key", - OH_SESSION_API_KEYS_0: "v1-key", - }, - ); - - expect(config.sessionApiKey).toBe("v0-key"); - }); - - it("SESSION_API_KEY takes precedence over all other session key env vars", async () => { - const config = await buildConfig( - {}, - { - SESSION_API_KEY: "v0-key", - OH_SESSION_API_KEYS_0: "v1-key", - VITE_SESSION_API_KEY: "vite-key", - }, - ); - - expect(config.sessionApiKey).toBe("v0-key"); + expect(config.localBackendApiKey).toBe("my-custom-key"); }); }); @@ -451,7 +374,7 @@ describe("dev-with-automation CLI", () => { expect(output).toContain("--dynamic"); expect(output).toContain("OH_AUTOMATION_GIT_REF"); expect(output).toContain("OH_AGENT_SERVER_LOCAL_PATH"); - expect(output).toContain("AUTOMATION_LOCAL_API_KEY"); + expect(output).toContain("LOCAL_BACKEND_API_KEY"); expect(output).toContain("OPENHANDS_AUTOMATION_API_KEY"); expect(output).toContain("SECRETS:"); }); diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 32e0e544f..a5c6447cc 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -15,7 +15,9 @@ # AUTOMATION_PORT – Internal automation port (default: 18001) # OH_SECRET_KEY – Secret key for settings encryption (auto-generated # and persisted if not provided) -# OPENHANDS_AUTOMATION_API_KEY – API key for automation backend auth +# OPENHANDS_AUTOMATION_API_KEY – Override automation backend auth key +# (defaults to the session API key so a single +# credential secures both backends) # Any agent-server or automation env vars are passed through. # ═══════════════════════════════════════════════════════════════════════════════ set -uo pipefail @@ -76,6 +78,18 @@ if [ -z "${OH_SESSION_API_KEYS_0:-}" ] && [ -z "${SESSION_API_KEY:-}" ]; then export OH_SESSION_API_KEYS_0="$SESSION_API_KEY" fi +# Both backends in the image share the same API key value: the agent-server +# accepts it via `X-Session-API-Key` and the automation sidecar accepts it +# via `Authorization: Bearer …`. Default the automation env vars to the +# session key so a single credential authenticates the whole stack — the +# frontend's only stored secret is the user-entered "session API key", and +# automation calls would 401 against the sidecar otherwise. Explicit values +# are still honored. +EFFECTIVE_SESSION_KEY="${OH_SESSION_API_KEYS_0:-${SESSION_API_KEY:-}}" +export OPENHANDS_AUTOMATION_API_KEY="${OPENHANDS_AUTOMATION_API_KEY:-${EFFECTIVE_SESSION_KEY}}" +export AUTOMATION_LOCAL_API_KEY="${AUTOMATION_LOCAL_API_KEY:-${EFFECTIVE_SESSION_KEY}}" +export AUTOMATION_AGENT_SERVER_API_KEY="${AUTOMATION_AGENT_SERVER_API_KEY:-${EFFECTIVE_SESSION_KEY}}" + # AGENT_SERVER_URL — needed by automation sandbox callbacks. export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}" diff --git a/playwright.live.config.ts b/playwright.live.config.ts index b1093663a..6cbe6c058 100644 --- a/playwright.live.config.ts +++ b/playwright.live.config.ts @@ -60,10 +60,11 @@ export default defineConfig({ "node -e \"const fs=require('node:fs'); for (const p of ['.tmp/live-e2e-state','node_modules/.vite']) fs.rmSync(p,{recursive:true,force:true});\" && " + [ "OH_CANVAS_SAFE_STATE_DIR=.tmp/live-e2e-state", - envAssignment("SESSION_API_KEY", liveE2ESessionApiKey), - envAssignment("OH_SESSION_API_KEYS_0", liveE2ESessionApiKey), + // Tell the dev-safe launcher to use this key for both the + // agent-server (OH_SESSION_API_KEYS_0) and the Vite-baked + // VITE_LOCAL_BACKEND_API_KEY. + envAssignment("LOCAL_BACKEND_API_KEY", liveE2ESessionApiKey), envAssignment("OH_CANVAS_SAFE_BACKEND_PORT", liveE2EBackendPort), - envAssignment("VITE_SESSION_API_KEY", liveE2ESessionApiKey), "VITE_DO_NOT_TRACK=1", "VITE_ENABLE_BROWSER_TOOLS=false", envAssignment("VITE_FRONTEND_PORT", liveE2EFrontendPort), diff --git a/scripts/dev-extra-backend.mjs b/scripts/dev-extra-backend.mjs index 7a906aab2..0bf513c97 100644 --- a/scripts/dev-extra-backend.mjs +++ b/scripts/dev-extra-backend.mjs @@ -148,7 +148,7 @@ async function main() { console.log( "Connect via the GUI: open Add Backend, enter " + `${config.backendBaseUrl} as the host. Leave the API key blank ` + - "unless this server is started with OH_SESSION_API_KEYS_0 set.", + "unless this server is started with LOCAL_BACKEND_API_KEY set.", ); console.log(""); diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs index 32cfecab8..46e6608de 100644 --- a/scripts/dev-safe.mjs +++ b/scripts/dev-safe.mjs @@ -5,6 +5,7 @@ import { mkdirSync, readdirSync, readFileSync, + renameSync, statSync, unlinkSync, writeFileSync, @@ -51,20 +52,19 @@ export function generateRandomApiKey() { return randomBytes(32).toString("hex"); } -// Where the auto-generated default session API key is persisted so it stays -// stable across `npm run dev` restarts. Keeping the key stable means the value -// baked into the frontend (VITE_SESSION_API_KEY) and the persisted -// backend-registry entry (`openhands-backends` localStorage) stay in sync -// without users needing to set anything in `.env`. +// Where the auto-generated default local-backend API key is persisted so it +// stays stable across `npm run dev` restarts. Keeping the key stable means +// the value baked into the frontend (VITE_LOCAL_BACKEND_API_KEY) and the +// persisted backend-registry entry (`openhands-backends` localStorage) stay +// in sync without users needing to set anything in `.env`. // // To rotate the key, delete this file. To pin a key explicitly, export -// SESSION_API_KEY (or OH_SESSION_API_KEYS_0 / VITE_SESSION_API_KEY) -- those -// take precedence over the persisted file. -export const DEFAULT_SESSION_API_KEY_PATH = path.join( +// LOCAL_BACKEND_API_KEY -- it takes precedence over the persisted file. +export const DEFAULT_LOCAL_BACKEND_API_KEY_PATH = path.join( homedir(), ".openhands", "agent-canvas", - "session-api-key.txt", + "local-backend-api-key.txt", ); // Cache so repeated lookups within a single process return the same key, @@ -72,20 +72,42 @@ export const DEFAULT_SESSION_API_KEY_PATH = path.join( const persistedApiKeyCache = new Map(); /** - * Load the persisted default session API key, generating + persisting one if - * the file doesn't exist yet. + * Load the persisted default local-backend API key, generating + persisting + * one if the file doesn't exist yet. * * Best-effort: if the file can't be written (e.g. read-only home dir), we * fall back to an in-memory key for this process so dev still works -- the * key just won't survive a restart. * * @param {string} filePath - Where to read/write the key. - * @returns {string} The (hex) session API key. + * @returns {string} The (hex) API key. */ -export function getOrCreatePersistedSessionApiKey( - filePath = DEFAULT_SESSION_API_KEY_PATH, +export function getOrCreatePersistedLocalBackendApiKey( + filePath = DEFAULT_LOCAL_BACKEND_API_KEY_PATH, ) { - return getOrCreatePersistedApiKey(filePath, "session"); + // One-shot migration: earlier versions persisted the agent-server's + // session key to a sibling `session-api-key.txt` (and a separate + // automation key to `automation-api-key.txt`). When our target file + // doesn't exist yet but the legacy session file does, rename it in so + // existing users don't get a fresh key on first run after the upgrade. + // The separate automation key file is intentionally not migrated -- it + // was never the right value to use for both backends. + const legacyPath = path.join(path.dirname(filePath), "session-api-key.txt"); + if ( + legacyPath !== filePath && + !existsSync(filePath) && + existsSync(legacyPath) + ) { + try { + renameSync(legacyPath, filePath); + console.log(`Migrated persisted API key: ${legacyPath} -> ${filePath}`); + } catch (error) { + console.warn( + `Could not migrate legacy API key file ${legacyPath} -> ${filePath}: ${error.message}. A new key will be generated.`, + ); + } + } + return getOrCreatePersistedApiKey(filePath, "local-backend"); } /** @@ -135,10 +157,11 @@ export function getOrCreatePersistedApiKey(filePath, label = "API") { } /** - * Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}. - * Intended for tests that swap the persisted file path between cases. + * Clear the in-memory cache used by {@link getOrCreatePersistedApiKey} + * (and {@link getOrCreatePersistedLocalBackendApiKey}). Intended for tests + * that swap the persisted file path between cases. */ -export function resetPersistedSessionApiKeyCache() { +export function resetPersistedApiKeyCache() { persistedApiKeyCache.clear(); } @@ -523,7 +546,7 @@ export async function buildSafeDevConfigAsync( * @property {string} backendHost * @property {string} workingDir * @property {string} secretKey - * @property {string} sessionApiKey + * @property {string} localBackendApiKey * @property {string} canvasToolsDir */ @@ -545,24 +568,20 @@ function buildConfigFromPorts(ports, cwd, env) { const workspacesPath = path.join(stateDir, "workspaces"); // Use provided secret key or default for local development const secretKey = env.OH_SECRET_KEY || DEFAULT_SECRET_KEY; - // Use provided session API key or fall back to a key persisted to - // ~/.openhands/agent-canvas/session-api-key.txt. Persisting on disk keeps - // the agent-server, the Vite-baked VITE_SESSION_API_KEY, and any - // `openhands-backends` localStorage entries the frontend has cached all - // pointing at the same value across dev restarts. + // Use the user-provided LOCAL_BACKEND_API_KEY, or fall back to a key + // persisted to ~/.openhands/agent-canvas/local-backend-api-key.txt. + // Persisting on disk keeps the agent-server, the automation backend, + // the Vite-baked VITE_LOCAL_BACKEND_API_KEY, and any `openhands-backends` + // localStorage entries the frontend has cached all pointing at the same + // value across dev restarts. // - // Check multiple env vars that may be used: - // - SESSION_API_KEY: Common name - // - OH_SESSION_API_KEYS_0: Used by agent-server V1 config - // - VITE_SESSION_API_KEY: Used by frontend config - // OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests). + // OH_LOCAL_BACKEND_API_KEY_PATH overrides the persisted file path (used + // by tests). const persistedKeyPath = - env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH; - const sessionApiKey = - env.SESSION_API_KEY || - env.OH_SESSION_API_KEYS_0 || - env.VITE_SESSION_API_KEY || - getOrCreatePersistedSessionApiKey(persistedKeyPath); + env.OH_LOCAL_BACKEND_API_KEY_PATH || DEFAULT_LOCAL_BACKEND_API_KEY_PATH; + const localBackendApiKey = + env.LOCAL_BACKEND_API_KEY || + getOrCreatePersistedLocalBackendApiKey(persistedKeyPath); // Host directory containing Agent-Canvas-specific Python tools (e.g. the // canvas_ui tool). Added to OH_EXTRA_PYTHON_PATH below so the agent-server @@ -583,7 +602,7 @@ function buildConfigFromPorts(ports, cwd, env) { backendHost: `127.0.0.1:${backendPort}`, workingDir: env.VITE_WORKING_DIR || workspacesPath, secretKey, - sessionApiKey, + localBackendApiKey, canvasToolsDir, }; } @@ -604,19 +623,15 @@ export function buildAgentServerEnv(config) { OH_BASH_EVENTS_DIR: config.bashEventsDir, OH_VSCODE_PORT: String(config.vscodePort), OH_SECRET_KEY: config.secretKey, - // Use OH_SESSION_API_KEYS_0 for agent-server V1 config format - OH_SESSION_API_KEYS_0: config.sessionApiKey, + // The agent-server reads its accepted API keys from OH_SESSION_API_KEYS_*. + // Feed it the unified local-backend key. + OH_SESSION_API_KEYS_0: config.localBackendApiKey, // Alias for the agent-server's own URL. The agent-server itself sets // OH_INTERNAL_SERVER_URL at startup, but downstream consumers (the // OpenHands SDK boilerplate emitted by automation prompt/plugin // presets) read AGENT_SERVER_URL — the canonical SDK name. Mirror it // here so automation runs work without each tarball having to know // about the OH_-prefixed variant. - // - // We deliberately do NOT set a SESSION_API_KEY alias: the SDK's - // sanitized_env() would strip it from bash subprocesses anyway, and - // a follow-up change to the automation preset reads - // OH_SESSION_API_KEYS_0 directly (which is already in env). AGENT_SERVER_URL: config.backendBaseUrl, // Make the host tools/ directory importable so the agent-server can // resolve modules listed in tool_module_qualnames (e.g. canvas_ui_tool). @@ -858,14 +873,12 @@ async function main() { ? "custom (from OH_SECRET_KEY)" : "default (for local development)"; - const sessionKeySource = - process.env.SESSION_API_KEY || - process.env.OH_SESSION_API_KEYS_0 || - process.env.VITE_SESSION_API_KEY - ? "custom (from env)" - : `persisted (${ - process.env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH - })`; + const localBackendKeySource = process.env.LOCAL_BACKEND_API_KEY + ? "custom (from LOCAL_BACKEND_API_KEY)" + : `persisted (${ + process.env.OH_LOCAL_BACKEND_API_KEY_PATH || + DEFAULT_LOCAL_BACKEND_API_KEY_PATH + })`; console.log(`- agent-server: ${agentServerCmd.source}`); console.log(`- backend: ${config.backendBaseUrl}`); @@ -873,7 +886,7 @@ async function main() { console.log(`- working dir: ${config.workingDir}`); console.log(`- isolated state dir: ${config.stateDir}`); console.log(`- secret key: ${secretKeySource}`); - console.log(`- session API key: ${sessionKeySource}`); + console.log(`- local backend API key: ${localBackendKeySource}`); console.log(""); const backend = spawnProcess( @@ -960,8 +973,9 @@ async function main() { VITE_BACKEND_HOST: config.backendHost, VITE_BACKEND_BASE_URL: config.backendBaseUrl, VITE_WORKING_DIR: config.workingDir, - // Pass session API key so frontend can authenticate with agent-server - VITE_SESSION_API_KEY: config.sessionApiKey, + // Pass the local-backend API key so the frontend can authenticate + // with both the agent-server and the automation backend. + VITE_LOCAL_BACKEND_API_KEY: config.localBackendApiKey, // Inform the frontend (and downstream, the agent's system prompt) about // which services are available in this dev stack. VITE_RUNTIME_SERVICES_INFO: JSON.stringify(runtimeServicesInfo), diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs index 9bdf675b3..df039e61b 100644 --- a/scripts/dev-static.mjs +++ b/scripts/dev-static.mjs @@ -322,11 +322,11 @@ function startAgentServer(config) { function buildAutomationBackendEnv(config) { return { AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`, - AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, + AUTOMATION_AGENT_SERVER_API_KEY: config.localBackendApiKey, AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`, AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`, AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"), - AUTOMATION_LOCAL_API_KEY: config.localApiKey, + AUTOMATION_LOCAL_API_KEY: config.localBackendApiKey, AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`, FILE_STORE: "local", LOCAL_STORAGE_PATH: join(config.stateDir, "storage"), diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index a8a82b841..6814359a7 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -35,19 +35,21 @@ * openhands-tools and openhands-workspace as editable so source edits are * picked up without manual reinstall. * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server - * - AUTOMATION_LOCAL_API_KEY: Custom API key for automation backend auth - * - OH_AUTOMATION_API_KEY_PATH: Override persisted default automation key path + * - LOCAL_BACKEND_API_KEY: Custom API key shared between agent-server and + * automation backend (and baked into the frontend as + * VITE_LOCAL_BACKEND_API_KEY) + * - OH_LOCAL_BACKEND_API_KEY_PATH: Override persisted default key path * * Secrets: - * The automation API key is automatically seeded into agent-server secrets - * as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. + * The local-backend API key is automatically seeded into agent-server + * secrets as OPENHANDS_AUTOMATION_API_KEY, making it available to agents + * in conversations that talk to the automation backend. */ import { spawn, spawnSync } from "node:child_process"; import { mkdirSync, existsSync, readFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { homedir } from "node:os"; import { setTimeout as delay } from "node:timers/promises"; import process from "node:process"; @@ -59,7 +61,6 @@ import { buildRuntimeServicesInfo, formatMissingUvxGuidance, findFreePorts, - getOrCreatePersistedApiKey, validateFrontendDependencies, validateLocalAgentServerPath, } from "./dev-safe.mjs"; @@ -86,15 +87,6 @@ const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation; const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.automationSdk; const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation; -// Where the auto-generated default automation API key is persisted. Static -// frontend builds bake VITE_AUTOMATION_API_KEY at build time, so the default -// must remain stable across restarts and --skip-build reuse. -const DEFAULT_AUTOMATION_API_KEY_PATH = join( - homedir(), - ".openhands", - "agent-canvas", - "automation-api-key.txt", -); // ═══════════════════════════════════════════════════════════════════════════ // Terminal Styling @@ -213,12 +205,16 @@ ENVIRONMENT VARIABLES: OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version) OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server OH_SECRET_KEY Secret key for sessions - AUTOMATION_LOCAL_API_KEY Custom API key for automation backend auth - OH_AUTOMATION_API_KEY_PATH Override persisted default automation key path + LOCAL_BACKEND_API_KEY Custom API key shared between the agent-server + and automation backend (and baked into the + frontend as VITE_LOCAL_BACKEND_API_KEY). + Defaults to a persisted auto-generated key. + OH_LOCAL_BACKEND_API_KEY_PATH Override the persisted key file path SECRETS: - The automation API key is automatically seeded into agent-server secrets - as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. + The local-backend API key is automatically seeded into agent-server secrets + as OPENHANDS_AUTOMATION_API_KEY, making it available to agents that talk + to the automation backend during conversations. ACCESS POINTS: Main UI: http://localhost:PORT/ @@ -339,25 +335,16 @@ async function buildConfig(args, env = process.env) { const vscodePort = ports.backend + 1000; - // Local API key for automation backend auth. Keep the generated default - // stable across restarts because static frontend builds bake this value. - const automationApiKeyPath = - env.OH_AUTOMATION_API_KEY_PATH || DEFAULT_AUTOMATION_API_KEY_PATH; - const localApiKey = - env.AUTOMATION_LOCAL_API_KEY || - getOrCreatePersistedApiKey(automationApiKeyPath, "automation"); - - // Session API key for agent-server auth - // Build a preliminary safe config to get the auto-generated session key - // This ensures both agent-server and frontend use the same key - const stateDir = join(homedir(), ".openhands", "agent-canvas"); + // Build the shared safe config: it resolves the unified local-backend API + // key (LOCAL_BACKEND_API_KEY → persisted file → auto-generated) and the + // state dir defaults. We then reuse `localBackendApiKey` as both the + // agent-server's accepted key (via OH_SESSION_API_KEYS_*) and the + // automation backend's auth key (AUTOMATION_LOCAL_API_KEY). const safeConfig = buildSafeDevConfig(projectRoot, { ...env, - OH_CANVAS_SAFE_STATE_DIR: stateDir, OH_CANVAS_SAFE_BACKEND_PORT: ports.backend.toString(), OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(), }); - const sessionApiKey = safeConfig.sessionApiKey; return { // Ingress port (main entry point) @@ -373,11 +360,10 @@ async function buildConfig(args, env = process.env) { canvasPath: projectRoot, // Data directories (same as dev-safe.mjs) - stateDir, + stateDir: safeConfig.stateDir, - // Auth - localApiKey, - sessionApiKey, + // Auth — one key, both backends. + localBackendApiKey: safeConfig.localBackendApiKey, verbose: args.verbose, }; @@ -535,7 +521,7 @@ function buildAgentServerAutomationEnv(config) { // > Secrets, but agents commonly create automations with a curl command // that references `$OPENHANDS_AUTOMATION_API_KEY`; exposing it here keeps // that path working even before/without secret-registry env expansion. - OPENHANDS_AUTOMATION_API_KEY: config.localApiKey, + OPENHANDS_AUTOMATION_API_KEY: config.localBackendApiKey, }; } @@ -628,7 +614,9 @@ function startAutomationBackend(config) { config.sandboxAgentServerUrl, } : {}), - AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, + // The automation backend authenticates against the agent-server + // using the same shared local-backend key. + AUTOMATION_AGENT_SERVER_API_KEY: config.localBackendApiKey, AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`, // The automation backend uses this as its publicly-reachable base // URL: it's appended to callback URLs and injected into each @@ -651,8 +639,10 @@ function startAutomationBackend(config) { process.env.AUTOMATION_WORKSPACE_BASE || config.automationWorkspaceBase || join(config.stateDir, "workspaces"), - // Local API key for self-hosted auth (no cloud API needed) - AUTOMATION_LOCAL_API_KEY: config.localApiKey, + // The automation backend uses AUTOMATION_LOCAL_API_KEY as its own + // accepted bearer token for incoming API calls. Reuse the unified + // local-backend key so clients only need one credential. + AUTOMATION_LOCAL_API_KEY: config.localBackendApiKey, // CORS: allow localhost origins for dev AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`, FILE_STORE: "local", @@ -775,17 +765,12 @@ function startVite(config) { VITE_WORKING_DIR: config.viteWorkingDir ?? join(config.stateDir, "workspaces"), VITE_FRONTEND_PORT: config.vitePort.toString(), - // Session API key for frontend to authenticate with agent-server - VITE_SESSION_API_KEY: config.sessionApiKey, - // Automation API key for frontend to authenticate with automation backend - VITE_AUTOMATION_API_KEY: config.localApiKey, + // Single shared key used to authenticate with both the agent-server + // (X-Session-API-Key header) and the automation backend (Bearer token). + VITE_LOCAL_BACKEND_API_KEY: config.localBackendApiKey, // Inform the frontend (and downstream, the agent's system prompt) about // which services are available in this dev stack. VITE_RUNTIME_SERVICES_INFO: JSON.stringify(runtimeServicesInfo), - // Session API key for agent-server auth (when SESSION_API_KEY is set) - ...(config.sessionApiKey && { - VITE_SESSION_API_KEY: config.sessionApiKey, - }), }, color: c.magenta, }); @@ -797,7 +782,7 @@ function startVite(config) { * * Includes retry logic to handle slow server startup or transient failures. * - * @param {object} config - Configuration object with agentServerPort, localApiKey, sessionApiKey + * @param {object} config - Configuration object with agentServerPort, localBackendApiKey * @param {object} options - Options for retry behavior * @param {number} options.maxRetries - Maximum number of retry attempts (default: 5) * @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000) @@ -816,14 +801,13 @@ async function seedAutomationSecret(config, options = {}) { const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`; const body = JSON.stringify({ name: secretName, - value: config.localApiKey, + value: config.localBackendApiKey, description: secretDescription, }); const headers = { "Content-Type": "application/json", - // Include session API key if configured - ...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }), + "X-Session-API-Key": config.localBackendApiKey, }; let lastError = null; @@ -1137,7 +1121,6 @@ export { DEFAULT_AUTOMATION_SDK_VERSION, DEFAULT_BACKEND_PORT, DEFAULT_AUTOMATION_PORT, - DEFAULT_AUTOMATION_API_KEY_PATH, }; // ═══════════════════════════════════════════════════════════════════════════ diff --git a/scripts/static-build.mjs b/scripts/static-build.mjs index 46df1e092..0b61dfcc4 100644 --- a/scripts/static-build.mjs +++ b/scripts/static-build.mjs @@ -44,13 +44,10 @@ export function buildFrontend(config, args = {}) { // to Vite. VITE_WORKING_DIR: config.viteWorkingDir ?? join(config.stateDir, "workspaces"), - // Bake the automation backend API key so the static frontend can talk - // to /api/automation through the ingress. - VITE_AUTOMATION_API_KEY: config.localApiKey, - // Bake the same session key the agent-server accepts. Without this, - // a fresh browser session seeds the Local backend with an empty key and - // all authenticated agent-server calls fail with 401. - VITE_SESSION_API_KEY: config.sessionApiKey, + // Bake the shared local-backend API key so the static frontend can + // authenticate with both the agent-server (X-Session-API-Key) and the + // automation backend (Bearer token) through the ingress. + VITE_LOCAL_BACKEND_API_KEY: config.localBackendApiKey, // Bake a description of the runtime services in this dev stack so the // frontend can populate the agent's system-prompt // block when creating a conversation. diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts index 9a7ab02d9..e05300939 100644 --- a/src/api/agent-server-config.ts +++ b/src/api/agent-server-config.ts @@ -82,7 +82,7 @@ function getConfiguredSessionApiKey(): string | null { const storedKey = trimToNull(readStoredConfig().sessionApiKey); if (storedKey) return storedKey; - return trimToNull(import.meta.env.VITE_SESSION_API_KEY); + return trimToNull(import.meta.env.VITE_LOCAL_BACKEND_API_KEY); } function shouldUseProxyOrigin(baseUrl: string): boolean { diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts index fcb999eed..1e178af7b 100644 --- a/src/api/automation-service/automation-service.api.ts +++ b/src/api/automation-service/automation-service.api.ts @@ -18,21 +18,28 @@ export interface AutomationHealthResponse { } // Local automation calls go to the automation sidecar that -// `scripts/dev-with-automation.mjs` mounts behind the local agent-server. -// That sidecar authenticates via its own `VITE_AUTOMATION_API_KEY` Bearer -// token — NOT the agent-server's `X-Session-API-Key` — so we cannot reuse -// the default local agent-server client for these calls. +// `scripts/dev-with-automation.mjs` (and the Docker entrypoint) mount +// behind the local agent-server. The sidecar accepts the same secret the +// agent-server accepts, just in a Bearer header instead of the +// `X-Session-API-Key` header — so we authenticate using the active +// backend's stored apiKey (the same value `getAgentServerHttpClientOptions` +// uses for the agent-server). We fall back to `VITE_LOCAL_BACKEND_API_KEY` +// only so the `npm run dev` flow can bootstrap before any user-edited +// backend record exists. const localAutomationAxios = axios.create(); localAutomationAxios.interceptors.request.use((config) => { + const backend = getEffectiveLocalBackend(); // Resolve the local backend host on every call so it tracks the // currently-active local backend (and any host edits made via the // manage-backends UI), rather than freezing whatever value the // agent-server-config produced at module load time. // eslint-disable-next-line no-param-reassign - if (!config.baseURL) config.baseURL = getEffectiveLocalBackend().host; + if (!config.baseURL) config.baseURL = backend.host; - const apiKey = import.meta.env.VITE_AUTOMATION_API_KEY?.trim(); + const apiKey = + backend.apiKey?.trim() || + import.meta.env.VITE_LOCAL_BACKEND_API_KEY?.trim(); if (apiKey) { config.headers.set("Authorization", `Bearer ${apiKey}`); } diff --git a/src/api/backend-registry/default-backend.ts b/src/api/backend-registry/default-backend.ts index 7a1db147c..af73e6b0f 100644 --- a/src/api/backend-registry/default-backend.ts +++ b/src/api/backend-registry/default-backend.ts @@ -20,7 +20,7 @@ export const DEFAULT_LOCAL_BACKEND_NAME = "Local"; /** * Construct the default local backend from environment / agent-server - * config (`VITE_BACKEND_BASE_URL`, `VITE_SESSION_API_KEY`, plus the + * config (`VITE_BACKEND_BASE_URL`, `VITE_LOCAL_BACKEND_API_KEY`, plus the * `openhands-agent-server-config` localStorage overrides). * * Used in two places: diff --git a/src/api/backend-registry/storage.ts b/src/api/backend-registry/storage.ts index cb89f9b54..6b39fd145 100644 --- a/src/api/backend-registry/storage.ts +++ b/src/api/backend-registry/storage.ts @@ -82,10 +82,11 @@ export function readStoredBackends(): Backend[] { // If the stored array is empty (or everything in it failed validation), // re-seed with the default Local backend so the user always has a - // working entry pointing at VITE_SESSION_API_KEY. With the dev scripts - // persisting that key to ~/.openhands/agent-canvas/session-api-key.txt, - // re-seeding is safe — the seeded entry will keep working across - // restarts instead of going stale. + // working entry pointing at VITE_LOCAL_BACKEND_API_KEY. With the dev + // scripts persisting that key to + // ~/.openhands/agent-canvas/local-backend-api-key.txt, re-seeding is + // safe — the seeded entry will keep working across restarts instead of + // going stale. if (valid.length === 0) { const seeded = [makeDefaultLocalBackend()]; writeStoredBackends(seeded); diff --git a/src/components/features/settings/sdk-settings/sdk-section-page.tsx b/src/components/features/settings/sdk-settings/sdk-section-page.tsx index 684d405ad..5260ca34c 100644 --- a/src/components/features/settings/sdk-settings/sdk-section-page.tsx +++ b/src/components/features/settings/sdk-settings/sdk-section-page.tsx @@ -85,7 +85,7 @@ const getSchemaUnavailableMessage = ( } if (error.response?.status === 401) { - return `${fallbackMessage} This agent server requires X-Session-API-Key. Set VITE_SESSION_API_KEY in the frontend to the same value used by the backend SESSION_API_KEY or OH_SESSION_API_KEYS_0.`; + return `${fallbackMessage} This agent server requires X-Session-API-Key. Set VITE_LOCAL_BACKEND_API_KEY in the frontend to the same value the backend was started with (LOCAL_BACKEND_API_KEY for the bundled dev launchers, OH_SESSION_API_KEYS_0 for a standalone agent-server).`; } if (error.response?.status === 404) { diff --git a/tests/e2e/live/utils/agent-server-conversation.ts b/tests/e2e/live/utils/agent-server-conversation.ts index 291e4bc5e..15467c60f 100644 --- a/tests/e2e/live/utils/agent-server-conversation.ts +++ b/tests/e2e/live/utils/agent-server-conversation.ts @@ -43,9 +43,8 @@ const llmModel = : "anthropic/claude-haiku-4-5-20251001"); export const sessionApiKey = firstNonEmpty( process.env.LIVE_E2E_SESSION_API_KEY, - process.env.SESSION_API_KEY, - process.env.OH_SESSION_API_KEYS_0, - process.env.VITE_SESSION_API_KEY, + process.env.LOCAL_BACKEND_API_KEY, + process.env.VITE_LOCAL_BACKEND_API_KEY, ); if (!sessionApiKey) {