diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000000000000000000000000000000..c8355999145c998b7a1cf0fedb84598bec20bd2d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,110 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "UTC" + cooldown: + default-days: 7 + versioning-strategy: increase + open-pull-requests-limit: 10 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - npm + # Group related packages together so they ship in a single PR. + # Packages that aren't matched by any group still get their own PR + # (the default behavior), which is what we want for high-impact deps + # like vite, react-router, framer-motion, etc. + groups: + tailwind: + patterns: + - "tailwindcss" + - "@tailwindcss/*" + - "tailwind-merge" + - "tailwind-scrollbar" + tanstack: + patterns: + - "@tanstack/*" + i18next: + patterns: + - "i18next" + - "i18next-*" + - "react-i18next" + - "eslint-plugin-i18next" + react: + patterns: + - "react" + - "react-dom" + - "@types/react" + - "@types/react-dom" + - "@types/react-*" + - "eslint-plugin-react" + - "eslint-plugin-react-hooks" + react-router: + patterns: + - "react-router" + - "@react-router/*" + - "@vercel/react-router" + - "isbot" + testing: + patterns: + - "vitest" + - "@vitest/*" + - "@testing-library/*" + - "jsdom" + - "@playwright/test" + - "msw" + - "@mswjs/*" + eslint: + patterns: + - "eslint" + - "eslint-config-*" + - "eslint-plugin-*" + - "@typescript-eslint/*" + - "prettier" + exclude-patterns: + # These are grouped under their feature area instead. + - "eslint-plugin-i18next" + - "eslint-plugin-react" + - "eslint-plugin-react-hooks" + monaco: + patterns: + - "monaco-editor" + - "@monaco-editor/*" + xterm: + patterns: + - "@xterm/*" + types: + patterns: + - "@types/*" + exclude-patterns: + - "@types/react" + - "@types/react-dom" + - "@types/react-*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: "UTC" + cooldown: + default-days: 7 + open-pull-requests-limit: 5 + commit-message: + prefix: ci + include: scope + labels: + - dependencies + - github-actions + groups: + actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..3cc55df8b1b23ef942b90027a427fcf272dbd1e0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,65 @@ + + +HUMAN: + + + +--- + +AGENT: + + + +## Why + + + +## Summary + + +- + +## Issue Number + +Fixes # + +## How to Test + + + +## Video/Screenshots + + + +## Type + +- [ ] Bug fix +- [ ] Feature +- [ ] Refactor +- [ ] Breaking change +- [ ] Docs / chore + +## Notes + + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..9d267f3a436b999afde69b1b5bbd3b1e1ed5bd92 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,14 @@ +changelog: + categories: + - title: Features + labels: ["type: feat"] + - title: Bug Fixes + labels: ["type: fix"] + - title: Performance + labels: ["type: perf"] + - title: Documentation + labels: ["type: docs"] + - title: Maintenance + labels: ["type: chore", "type: build", "type: ci", "type: refactor", "type: style", "type: test", "type: revert"] + - title: Other Changes + labels: ["*"] diff --git a/__tests__/MSW.md b/__tests__/MSW.md new file mode 100644 index 0000000000000000000000000000000000000000..8cd0afb5f7763ad3ad1af2e9c2f6b23ce482efcb --- /dev/null +++ b/__tests__/MSW.md @@ -0,0 +1,134 @@ +# Mock Service Worker (MSW) Guide + +## Overview + +[Mock Service Worker (MSW)](https://mswjs.io/) is an API mocking library that intercepts outgoing network requests at the network level. Unlike traditional mocking that patches `fetch` or `axios`, MSW uses a Service Worker in the browser and direct request interception in Node.js—making mocks transparent to your application code. + +We use MSW in this project for: +- **Testing**: Write reliable unit and integration tests without real network calls +- **Development**: Run the frontend with mocked APIs when the backend isn't available or when working on features with pending backend APIs + +The same mock handlers work in both environments, so you write them once and reuse everywhere. + +## Relevant Files + +- `src/mocks/handlers.ts` - Main handler registry that combines all domain handlers +- `src/mocks/*-handlers.ts` - Domain-specific handlers (auth, conversation, etc.) +- `src/mocks/browser.ts` - Browser setup for development mode +- `src/mocks/node.ts` - Node.js setup for tests +- `vitest.setup.ts` - Global test setup with MSW lifecycle hooks + +## Development Workflow + +### Running with Mocked APIs + +```sh +# Run with API mocking enabled +npm run dev:mock +``` + +This command sets `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests. + + +## Writing Tests + +### Service Layer Mocking (Recommended) + +For most tests, mock at the service layer using `vi.spyOn`. This approach is explicit, test-scoped, and makes the scenario being tested clear. + +```typescript +import { vi } from "vitest"; +import SettingsService from "#/api/settings-service/settings-service.api"; + +const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); +getSettingsSpy.mockResolvedValue({ + llm_model: "openai/gpt-4o", + llm_api_key_set: true, + // ... other settings +}); +``` + +Use `mockResolvedValue` for success scenarios and `mockRejectedValue` for error scenarios: + +```typescript +getSettingsSpy.mockRejectedValue(new Error("Failed to fetch settings")); +``` + +### Network Layer Mocking (Advanced) + +For tests that need actual network-level behavior (WebSockets, testing retry logic, etc.), use `server.use()` to override handlers per test. + +> [!IMPORTANT] +> **Reuse the global server instance** - Don't create new `setupServer()` calls in individual tests. The project already has a global MSW server configured in `vitest.setup.ts` that handles lifecycle (`server.listen()`, `server.resetHandlers()`, `server.close()`). Use `server.use()` to add runtime handlers for specific test scenarios. + +```typescript +import { http, HttpResponse } from "msw"; +import { server } from "#/mocks/node"; + +it("should handle server errors", async () => { + server.use( + http.get("/api/my-endpoint", () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + // ... test code +}); +``` + +For WebSocket testing, see `__tests__/helpers/msw-websocket-setup.ts` for utilities. + +## Adding New API Mocks + +When adding new API endpoints, create mocks in both places to maintain 1:1 similarity with the backend: + +### 1. Add to `src/mocks/` (for development) + +Create or update a domain-specific handler file: + +```typescript +// src/mocks/my-feature-handlers.ts +import { http, HttpResponse } from "msw"; + +export const MY_FEATURE_HANDLERS = [ + http.get("/api/my-feature", () => { + return HttpResponse.json({ + data: "mock response", + }); + }), +]; +``` + +Register in `handlers.ts`: + +```typescript +import { MY_FEATURE_HANDLERS } from "./my-feature-handlers"; + +export const handlers = [ + // ... existing handlers + ...MY_FEATURE_HANDLERS, +]; +``` + +### 2. Mock in tests for specific scenarios + +In your test files, spy on the service method to control responses per test case: + +```typescript +import { vi } from "vitest"; +import MyFeatureService from "#/api/my-feature-service.api"; + +const spy = vi.spyOn(MyFeatureService, "getData"); +spy.mockResolvedValue({ data: "test-specific response" }); +``` + +See `__tests__/routes/llm-settings.test.tsx` for a real-world example of service layer mocking. + +> [!TIP] +> For guidance on creating service APIs, see `src/api/README.md`. + +## Best Practices + +- **Keep mocks close to real API contracts** - Update mocks when backend changes +- **Use service layer mocking for most tests** - It's simpler and more explicit +- **Reserve network layer mocking for integration tests** - WebSockets, retry logic, etc. +- **Export mock data from handler files** - Reuse in tests (e.g., `MOCK_DEFAULT_USER_SETTINGS`) diff --git a/__tests__/agent-server-ui-providers.test.tsx b/__tests__/agent-server-ui-providers.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e303a146bc27b4ef33853513e1d144b82f943136 --- /dev/null +++ b/__tests__/agent-server-ui-providers.test.tsx @@ -0,0 +1,279 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import { initReactI18next, useTranslation } from "react-i18next"; + +vi.mock("react-i18next", async (importOriginal) => + importOriginal(), +); + +import { + AGENT_SERVER_UI_SCOPE_SELECTOR, + AgentServerUIRoot, + AgentServerUIProviders, + OPENHANDS_I18N_NAMESPACE, + getDefaultI18n, + getDefaultQueryClient, + getI18n, + getQueryClient, + queryClient, + setI18n, + setQueryClient, +} from "#/index"; +import i18n from "#/i18n"; + +const telemetryProviderMock = vi.hoisted(() => vi.fn()); +vi.mock("#/components/providers/telemetry-provider", () => ({ + TelemetryProvider: (props: { + children: React.ReactNode; + config?: unknown; + }) => { + telemetryProviderMock(props); + return props.children; + }, +})); + +const BaseProbe = ({ translation }: { translation?: string }) => { + const currentQueryClient = useQueryClient(); + + return ( +
+
+ {currentQueryClient === getDefaultQueryClient() ? "default" : "custom"} +
+
+ {String(queryClient.getQueryData(["provider-probe"]))} +
+ {translation &&
{translation}
} +
+ {i18n.t("PROVIDER$LABEL")} +
+
+ ); +}; + +const DefaultProbe = () => ; + +const CustomProbe = () => { + const { t } = useTranslation(OPENHANDS_I18N_NAMESPACE); + + return ; +}; + +const createTestI18n = async (value: string) => { + const instance = createInstance(); + + await instance.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + ns: ["host", OPENHANDS_I18N_NAMESPACE], + defaultNS: "host", + interpolation: { escapeValue: false }, + resources: { + en: { + host: { + PROVIDER$LABEL: "Host provider", + }, + [OPENHANDS_I18N_NAMESPACE]: { + PROVIDER$LABEL: value, + }, + }, + }, + }); + + return instance; +}; + +afterEach(() => { + cleanup(); + getDefaultQueryClient().removeQueries({ queryKey: ["provider-probe"] }); + setQueryClient(); + setI18n(); + vi.restoreAllMocks(); +}); + +describe("AgentServerUIProviders", () => { + it("exports and uses the default query client and i18n instances when props are omitted", async () => { + const defaultI18n = getDefaultI18n(); + + defaultI18n.addResourceBundle( + "en", + OPENHANDS_I18N_NAMESPACE, + { PROVIDER$LABEL: "Default provider" }, + true, + true, + ); + await defaultI18n.changeLanguage("en"); + + getDefaultQueryClient().setQueryData(["provider-probe"], "default-client"); + + render( + + + , + ); + + expect(screen.getByTestId("query-client-kind")).toHaveTextContent( + "default", + ); + expect(screen.getByTestId("query-client-value")).toHaveTextContent( + "default-client", + ); + + await waitFor(() => { + expect( + screen.getByTestId("imperative-translation-value"), + ).toHaveTextContent("Default provider"); + }); + + expect(getQueryClient()).toBe(getDefaultQueryClient()); + }); + + it("injects a custom query client and i18n instance without conflicting with imperative callers", async () => { + const customQueryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const customI18n = await createTestI18n("Custom provider"); + + customQueryClient.setQueryData(["provider-probe"], "custom-client"); + + const view = render( + + + , + ); + + expect(screen.getByTestId("query-client-kind")).toHaveTextContent("custom"); + expect(screen.getByTestId("query-client-value")).toHaveTextContent( + "custom-client", + ); + + await waitFor(() => { + expect(screen.getByTestId("translation-value")).toHaveTextContent( + "Custom provider", + ); + expect( + screen.getByTestId("imperative-translation-value"), + ).toHaveTextContent("Custom provider"); + }); + + expect(getQueryClient()).toBe(customQueryClient); + expect(getI18n()).toBe(customI18n); + + view.unmount(); + + expect(getQueryClient()).toBe(getDefaultQueryClient()); + expect(getI18n()).toBe(getDefaultI18n()); + }); + + it("passes disabled and runtime analytics configuration to TelemetryProvider", () => { + telemetryProviderMock.mockClear(); + + const noAnalyticsView = render( + +
child
+
, + ); + + expect(screen.getByTestId("child")).toHaveTextContent("child"); + expect(telemetryProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ config: false }), + ); + + noAnalyticsView.unmount(); + telemetryProviderMock.mockClear(); + + const analytics = { + provider: "posthog" as const, + apiKey: "phc_embedded", + apiHost: "https://events.example.com", + uiHost: "https://posthog.example.com", + }; + + render( + +
child
+
, + ); + + expect(telemetryProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: { + apiKey: analytics.apiKey, + apiHost: analytics.apiHost, + uiHost: analytics.uiHost, + }, + }), + ); + }); + + it("wraps children in a scoped, customizable style root by default", () => { + const { unmount } = render( + +
child
+
, + ); + + const scopeRoot = document.querySelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ); + + expect(scopeRoot).toBeInTheDocument(); + expect(scopeRoot?.style.getPropertyValue("--oh-color-base")).toBe( + "#010203", + ); + + const themedContainer = + scopeRoot?.firstElementChild as HTMLDivElement | null; + expect(themedContainer).toHaveAttribute("data-theme", "dark"); + expect(themedContainer).toHaveClass("dark", "min-h-screen"); + expect(themedContainer).toContainElement( + screen.getByTestId("styled-child"), + ); + + unmount(); + + render( + +
child
+
, + ); + + expect(document.querySelector(AGENT_SERVER_UI_SCOPE_SELECTOR)).toBeNull(); + }); + + it("exposes a standalone style root for host-controlled customization", () => { + render( + +
child
+
, + ); + + const scopeRoot = document.querySelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ); + + expect(scopeRoot).toHaveClass("outer-shell"); + expect(scopeRoot?.style.getPropertyValue("--oh-color-primary")).toBe( + "#abcdef", + ); + + const themedContainer = + scopeRoot?.firstElementChild as HTMLDivElement | null; + expect(themedContainer).toHaveAttribute("data-theme", "light"); + expect(themedContainer).toHaveClass("light", "inner-shell"); + expect(themedContainer).toContainElement(screen.getByTestId("root-child")); + }); +}); diff --git a/__tests__/agent-server-ui-style-scope.test.ts b/__tests__/agent-server-ui-style-scope.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8335ae10b712465da70bda3c2d7b7174a32753a2 --- /dev/null +++ b/__tests__/agent-server-ui-style-scope.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_SERVER_UI_SCOPE_SELECTOR, + transformAgentServerUISelector, +} from "#/styles/agent-server-ui-style-scope"; + +describe("transformAgentServerUISelector", () => { + it("prefixes ordinary selectors under the scoped root", () => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ".button-base", + `${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`, + ), + ).toBe(`${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`); + }); + + it("replaces :host selectors with the scoped root", () => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + ":host", + `${AGENT_SERVER_UI_SCOPE_SELECTOR} :host`, + ), + ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); + }); + + it.each([":root", "body", "html"])( + "maps %s selectors directly to the scoped root", + (selector) => { + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + selector, + `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, + ), + ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); + }, + ); + + it("does not double-prefix selectors that are already scoped", () => { + const selector = `${AGENT_SERVER_UI_SCOPE_SELECTOR} .xterm`; + + expect( + transformAgentServerUISelector( + AGENT_SERVER_UI_SCOPE_SELECTOR, + selector, + `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, + ), + ).toBe(selector); + }); +}); diff --git a/__tests__/build-websocket-url.test.ts b/__tests__/build-websocket-url.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..41c30fe8511013c7f425eae18c7b30c9104cd873 --- /dev/null +++ b/__tests__/build-websocket-url.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { buildWebSocketUrl } from "#/utils/websocket-url"; + +describe("buildWebSocketUrl", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("Basic URL construction", () => { + it("should build WebSocket URL with conversation ID and URL", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + + const result = buildWebSocketUrl( + "conv-123", + "http://localhost:8080/api/conversations/conv-123", + ); + + expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); + }); + + it("should use wss:// protocol when window.location.protocol is https:", () => { + vi.stubGlobal("location", { + protocol: "https:", + host: "localhost:3000", + }); + + const result = buildWebSocketUrl( + "conv-123", + "https://example.com:8080/api/conversations/conv-123", + ); + + expect(result).toBe("wss://example.com:8080/sockets/events/conv-123"); + }); + + it("should use ws:// for external HTTP hosts when page is HTTP", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + + const result = buildWebSocketUrl( + "conv-456", + "http://agent-server.com:9000/api/conversations/conv-456", + ); + + expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456"); + }); + + it("should use wss:// for external HTTPS hosts when page is HTTP", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + + const result = buildWebSocketUrl( + "conv-456", + "https://agent-server.com:9000/api/conversations/conv-456", + ); + + expect(result).toBe( + "wss://agent-server.com:9000/sockets/events/conv-456", + ); + }); + + it("should use ws:// for localhost when page is HTTP", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + + const result = buildWebSocketUrl( + "conv-456", + "http://127.0.0.1:9000/api/conversations/conv-456", + ); + + expect(result).toBe("ws://127.0.0.1:9000/sockets/events/conv-456"); + }); + }); + + describe("Query parameters handling", () => { + beforeEach(() => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + }); + + it("should not include query parameters in the URL (handled by useWebSocket hook)", () => { + const result = buildWebSocketUrl( + "conv-123", + "http://localhost:8080/api/conversations/conv-123", + ); + + expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); + expect(result).not.toContain("?"); + expect(result).not.toContain("session_api_key"); + }); + }); + + describe("Fallback to window.location.host", () => { + it("should use window.location.host when conversation URL is null", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "fallback-host:4000", + }); + + const result = buildWebSocketUrl("conv-123", null); + + expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); + }); + + it("should use window.location.host when conversation URL is undefined", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "fallback-host:4000", + }); + + const result = buildWebSocketUrl("conv-123", undefined); + + expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); + }); + + it("should use window.location.host when conversation URL is relative path", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "fallback-host:4000", + }); + + const result = buildWebSocketUrl( + "conv-123", + "/api/conversations/conv-123", + ); + + expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); + }); + + it("should use window.location.host when conversation URL is invalid", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "fallback-host:4000", + }); + + const result = buildWebSocketUrl("conv-123", "not-a-valid-url"); + + expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); + }); + }); + + describe("Edge cases", () => { + beforeEach(() => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:3000", + }); + }); + + it("should return null when conversationId is undefined", () => { + const result = buildWebSocketUrl( + undefined, + "http://localhost:8080/api/conversations/conv-123", + ); + + expect(result).toBeNull(); + }); + + it("should return null when conversationId is empty string", () => { + const result = buildWebSocketUrl( + "", + "http://localhost:8080/api/conversations/conv-123", + ); + + expect(result).toBeNull(); + }); + + it("should handle conversation URLs with non-standard ports on external hosts", () => { + const result = buildWebSocketUrl( + "conv-123", + "http://example.com:12345/api/conversations/conv-123", + ); + + expect(result).toBe("ws://example.com:12345/sockets/events/conv-123"); + }); + + it("should handle conversation URLs without port (default port) on external hosts", () => { + const result = buildWebSocketUrl( + "conv-123", + "http://example.com/api/conversations/conv-123", + ); + + expect(result).toBe("ws://example.com/sockets/events/conv-123"); + }); + + it("should handle conversation IDs with special characters", () => { + const result = buildWebSocketUrl( + "conv-123-abc_def", + "http://localhost:8080/api/conversations/conv-123-abc_def", + ); + + expect(result).toBe( + "ws://localhost:8080/sockets/events/conv-123-abc_def", + ); + }); + + it("should build URL without query parameters", () => { + const result = buildWebSocketUrl( + "conv-123", + "http://localhost:8080/api/conversations/conv-123", + ); + + expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); + expect(result).not.toContain("?"); + }); + }); + + describe("protocol selection for external hosts", () => { + it("should use wss:// for HTTPS prod-runtime.all-hands.dev domains", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "localhost:8000", + }); + + // Use obviously fake IDs that follow the format pattern + const fakeConversationId = "00000000deadbeef0000000000000000"; + const fakeRuntimeHost = "faketesthost.prod-runtime.all-hands.dev"; + + const result = buildWebSocketUrl( + fakeConversationId, + `https://${fakeRuntimeHost}/api/conversations/${fakeConversationId}`, + ); + + expect(result).toBe( + `wss://${fakeRuntimeHost}/sockets/events/${fakeConversationId}`, + ); + }); + + it("should use ws:// for ::1 (IPv6 localhost)", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "[::1]:3000", + }); + + const result = buildWebSocketUrl( + "test-conv-ipv6", + "http://[::1]:8080/api/conversations/test-conv-ipv6", + ); + + expect(result).toBe("ws://[::1]:8080/sockets/events/test-conv-ipv6"); + }); + + it("should use ws:// for .localhost subdomains", () => { + vi.stubGlobal("location", { + protocol: "http:", + host: "app.localhost:3000", + }); + + const result = buildWebSocketUrl( + "test-conv-localhost-subdomain", + "http://api.localhost:8080/api/conversations/test-conv-localhost-subdomain", + ); + + expect(result).toBe( + "ws://api.localhost:8080/sockets/events/test-conv-localhost-subdomain", + ); + }); + }); +}); diff --git a/__tests__/conversation-local-storage.test.ts b/__tests__/conversation-local-storage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2b6dc430852e696adb412f1244218ac4479ca94 --- /dev/null +++ b/__tests__/conversation-local-storage.test.ts @@ -0,0 +1,692 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + clearConversationLocalStorage, + getConversationState, + isTaskConversationId, + setConversationState, + LOCAL_STORAGE_KEYS, +} from "#/utils/conversation-local-storage"; + +describe("conversation localStorage utilities", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("isTaskConversationId", () => { + it("returns true for IDs starting with task-", () => { + expect(isTaskConversationId("task-abc-123")).toBe(true); + expect(isTaskConversationId("task-")).toBe(true); + }); + + it("returns false for normal conversation IDs", () => { + expect(isTaskConversationId("conv-123")).toBe(false); + expect(isTaskConversationId("abc")).toBe(false); + }); + }); + + describe("getConversationState", () => { + it("returns default state including conversationMode for task IDs without reading localStorage", () => { + const state = getConversationState("task-uuid-123"); + + expect(state.conversationMode).toBe("code"); + expect(state.selectedTab).toBe("files"); + expect( + localStorage.getItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-uuid-123`, + ), + ).toBeNull(); + }); + + it("returns merged state from localStorage for real conversation ID including conversationMode", () => { + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-1`; + localStorage.setItem( + key, + JSON.stringify({ conversationMode: "plan", selectedTab: "terminal" }), + ); + + const state = getConversationState("conv-1"); + + expect(state.conversationMode).toBe("plan"); + expect(state.selectedTab).toBe("terminal"); + }); + + it("round-trips rightPanelShown through localStorage", () => { + const conversationId = "conv-right-panel"; + setConversationState(conversationId, { + selectedTab: "terminal", + rightPanelShown: true, + unpinnedTabs: ["browser"], + }); + + const state = getConversationState(conversationId); + + expect(state.selectedTab).toBe("terminal"); + expect(state.unpinnedTabs).toEqual(["browser"]); + expect(state.rightPanelShown).toBe(true); + }); + + it("defaults rightPanelShown to false and drops corrupt values", () => { + expect(getConversationState("conv-right-panel-default").rightPanelShown).toBe( + false, + ); + + const conversationId = "conv-right-panel-corrupt"; + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.setItem( + key, + JSON.stringify({ + selectedTab: "terminal", + rightPanelShown: "yes", + }), + ); + + expect(getConversationState(conversationId).rightPanelShown).toBe(false); + }); + + it("returns default state when key is missing or invalid", () => { + expect(getConversationState("conv-missing").conversationMode).toBe( + "code", + ); + + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-bad`; + localStorage.setItem(key, "not json"); + expect(getConversationState("conv-bad").conversationMode).toBe("code"); + }); + }); + + describe("setConversationState", () => { + it("does not persist when conversationId is a task ID", () => { + setConversationState("task-xyz", { conversationMode: "plan" }); + + expect( + localStorage.getItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-xyz`, + ), + ).toBeNull(); + }); + + it("persists conversationMode for real conversation ID and getConversationState returns it", () => { + setConversationState("conv-2", { conversationMode: "plan" }); + + const state = getConversationState("conv-2"); + expect(state.conversationMode).toBe("plan"); + }); + }); + + describe("clearConversationLocalStorage", () => { + it("removes the consolidated conversation-state localStorage entry", () => { + const conversationId = "conv-123"; + + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "editor", + unpinnedTabs: [], + }), + ); + + clearConversationLocalStorage(conversationId); + + expect(localStorage.getItem(consolidatedKey)).toBeNull(); + }); + + it("does not throw if conversation keys do not exist", () => { + expect(() => { + clearConversationLocalStorage("non-existent-id"); + }).not.toThrow(); + }); + }); + + describe("getConversationState", () => { + it("returns default state with subConversationTaskId as null when no state exists", () => { + const conversationId = "conv-123"; + const state = getConversationState(conversationId); + + expect(state.subConversationTaskId).toBeNull(); + expect(state.selectedTab).toBe("files"); + expect(state.unpinnedTabs).toEqual([]); + expect(state.unpinnedOverviewSections).toEqual([]); + expect(state.unpinnedOverviewGitParts).toEqual([]); + }); + + it("persists and sanitizes unpinnedOverviewSections", () => { + const conversationId = "conv-overview-pins"; + setConversationState(conversationId, { + unpinnedOverviewSections: ["skills", "not-a-section", "mcp", "workspace"], + }); + + const state = getConversationState(conversationId); + // Legacy section ids (mcp/skills/secrets/…) are dropped by the allowlist. + expect(state.unpinnedOverviewSections).toEqual(["workspace"]); + }); + + it("persists and sanitizes unpinnedOverviewGitParts", () => { + const conversationId = "conv-overview-git-pins"; + setConversationState(conversationId, { + unpinnedOverviewGitParts: ["branch", "not-a-part", "issues"], + }); + + const state = getConversationState(conversationId); + // Legacy git part ids (issues) are dropped by the allowlist. + expect(state.unpinnedOverviewGitParts).toEqual(["branch"]); + }); + + it("retrieves subConversationTaskId from localStorage when it exists", () => { + const conversationId = "conv-123"; + const taskId = "task-uuid-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "editor", + unpinnedTabs: [], + subConversationTaskId: taskId, + }), + ); + + const state = getConversationState(conversationId); + + expect(state.subConversationTaskId).toBe(taskId); + }); + + it("merges stored state with defaults when partial state exists", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + subConversationTaskId: "task-123", + }), + ); + + const state = getConversationState(conversationId); + + expect(state.subConversationTaskId).toBe("task-123"); + expect(state.selectedTab).toBe("files"); + expect(state.unpinnedTabs).toEqual([]); + }); + + it("falls back to the default tab when stored selectedTab is no longer valid", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + // Persisted from a previous app version where "editor" was a tab. + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "editor", + unpinnedTabs: [], + }), + ); + + const state = getConversationState(conversationId); + + expect(state.selectedTab).toBe("files"); + }); + + it("migrates a stored Diffs (changes) tab selection to Commits", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "changes", + unpinnedTabs: [], + }), + ); + + const state = getConversationState(conversationId); + + expect(state.selectedTab).toBe("commits"); + }); + + it("filters obsolete tabs out of stored unpinnedTabs (editor / served / app / changes)", () => { + // Returning users may have unpinned the now-removed Editor, Served, + // App, or Diffs (`changes`) tabs in a previous version. Those names + // should not survive the read — otherwise they linger forever in + // localStorage since the UI has no way to surface them again to be + // re-pinned. + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "files", + unpinnedTabs: ["editor", "changes", "served", "app", "terminal"], + }), + ); + + const state = getConversationState(conversationId); + + // Obsolete names are dropped; still-valid `terminal` stays. + expect(state.unpinnedTabs).toEqual(["terminal"]); + }); + }); + + describe("setConversationState", () => { + it("persists subConversationTaskId to localStorage", () => { + const conversationId = "conv-123"; + const taskId = "task-uuid-456"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + setConversationState(conversationId, { + subConversationTaskId: taskId, + }); + + const stored = localStorage.getItem(consolidatedKey); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed.subConversationTaskId).toBe(taskId); + }); + + it("merges subConversationTaskId with existing state", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + // Set initial state + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "browser", + unpinnedTabs: ["tab-1"], + subConversationTaskId: "old-task-id", + }), + ); + + // Update only subConversationTaskId + setConversationState(conversationId, { + subConversationTaskId: "new-task-id", + }); + + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + + expect(parsed.subConversationTaskId).toBe("new-task-id"); + expect(parsed.selectedTab).toBe("browser"); + expect(parsed.unpinnedTabs).toEqual(["tab-1"]); + }); + + it("clears subConversationTaskId when set to null", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + // Set initial state with task ID + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + subConversationTaskId: "task-123", + }), + ); + + // Clear the task ID + setConversationState(conversationId, { + subConversationTaskId: null, + }); + + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + + expect(parsed.subConversationTaskId).toBeNull(); + }); + }); + + describe("draftMessage persistence", () => { + describe("getConversationState", () => { + it("returns default draftMessage as null when no state exists", () => { + // Arrange + const conversationId = "conv-draft-1"; + + // Act + const state = getConversationState(conversationId); + + // Assert + expect(state.draftMessage).toBeNull(); + }); + + it("retrieves draftMessage from localStorage when it exists", () => { + // Arrange + const conversationId = "conv-draft-2"; + const draftText = "This is my saved draft message"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: draftText, + }), + ); + + // Act + const state = getConversationState(conversationId); + + // Assert + expect(state.draftMessage).toBe(draftText); + }); + + it("returns null draftMessage for task conversation IDs (not persisted)", () => { + // Arrange + const taskId = "task-uuid-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; + + // Even if somehow there's data in localStorage for a task ID + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Should not be returned", + }), + ); + + // Act + const state = getConversationState(taskId); + + // Assert - should return default state, not the stored value + expect(state.draftMessage).toBeNull(); + }); + }); + + describe("setConversationState", () => { + it("persists draftMessage to localStorage", () => { + // Arrange + const conversationId = "conv-draft-3"; + const draftText = "New draft message to save"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + // Act + setConversationState(conversationId, { + draftMessage: draftText, + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + expect(stored).not.toBeNull(); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBe(draftText); + }); + + it("does not persist draftMessage for task conversation IDs", () => { + // Arrange + const taskId = "task-draft-xyz"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; + + // Act + setConversationState(taskId, { + draftMessage: "Draft for task ID", + }); + + // Assert - nothing should be stored + expect(localStorage.getItem(consolidatedKey)).toBeNull(); + }); + + it("merges draftMessage with existing state without overwriting other fields", () => { + // Arrange + const conversationId = "conv-draft-4"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "terminal", + unpinnedTabs: ["tab-1", "tab-2"], + conversationMode: "plan", + subConversationTaskId: "task-123", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: "Updated draft", + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + + expect(parsed.draftMessage).toBe("Updated draft"); + expect(parsed.selectedTab).toBe("terminal"); + expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]); + expect(parsed.conversationMode).toBe("plan"); + expect(parsed.subConversationTaskId).toBe("task-123"); + }); + + it("clears draftMessage when set to null", () => { + // Arrange + const conversationId = "conv-draft-5"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Existing draft", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: null, + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBeNull(); + }); + + it("clears draftMessage when set to empty string (stored as empty string)", () => { + // Arrange + const conversationId = "conv-draft-6"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + draftMessage: "Existing draft", + }), + ); + + // Act + setConversationState(conversationId, { + draftMessage: "", + }); + + // Assert + const stored = localStorage.getItem(consolidatedKey); + const parsed = JSON.parse(stored!); + expect(parsed.draftMessage).toBe(""); + }); + }); + + describe("conversation-specific draft isolation", () => { + it("stores drafts separately for different conversations", () => { + // Arrange + const convA = "conv-A"; + const convB = "conv-B"; + const draftA = "Draft for conversation A"; + const draftB = "Draft for conversation B"; + + // Act + setConversationState(convA, { draftMessage: draftA }); + setConversationState(convB, { draftMessage: draftB }); + + // Assert + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBe(draftA); + expect(stateB.draftMessage).toBe(draftB); + }); + + it("updating one conversation draft does not affect another", () => { + // Arrange + const convA = "conv-isolated-A"; + const convB = "conv-isolated-B"; + + setConversationState(convA, { draftMessage: "Original draft A" }); + setConversationState(convB, { draftMessage: "Original draft B" }); + + // Act - update only conversation A + setConversationState(convA, { draftMessage: "Updated draft A" }); + + // Assert - conversation B should be unchanged + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBe("Updated draft A"); + expect(stateB.draftMessage).toBe("Original draft B"); + }); + + it("clearing one conversation draft does not affect another", () => { + // Arrange + const convA = "conv-clear-A"; + const convB = "conv-clear-B"; + + setConversationState(convA, { draftMessage: "Draft A" }); + setConversationState(convB, { draftMessage: "Draft B" }); + + // Act - clear draft for conversation A + setConversationState(convA, { draftMessage: null }); + + // Assert + const stateA = getConversationState(convA); + const stateB = getConversationState(convB); + + expect(stateA.draftMessage).toBeNull(); + expect(stateB.draftMessage).toBe("Draft B"); + }); + }); + }); + + describe("filesTabDiffView preference", () => { + it("preserves filesTabDiffView from stored blobs on read", () => { + const conversationId = "files-diff-legacy"; + localStorage.setItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`, + JSON.stringify({ + selectedTab: "files", + filesTabDiffView: true, + }), + ); + + const state = getConversationState(conversationId); + expect(state.filesTabDiffView).toBe(true); + }); + }); + + describe("filesTabContentViewMode persistence", () => { + // The rich/plain toggle for the file content viewer also persists + // per conversation. Default is "rich" — verified explicitly here so + // a careless change to the default field initializer doesn't slip + // through unnoticed (it would flip every existing user from rich to + // plain after deploy). + + it("defaults to 'rich' when nothing is stored", () => { + const state = getConversationState("files-view-conv-1"); + expect(state.filesTabContentViewMode).toBe("rich"); + }); + + it("round-trips 'plain' through localStorage", () => { + const conversationId = "files-view-conv-2"; + setConversationState(conversationId, { + filesTabContentViewMode: "plain", + }); + + expect(getConversationState(conversationId).filesTabContentViewMode).toBe( + "plain", + ); + + const raw = localStorage.getItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`, + ); + expect(JSON.parse(raw as string).filesTabContentViewMode).toBe("plain"); + }); + + it("round-trips 'rich' through localStorage (explicit save, not default)", () => { + const conversationId = "files-view-conv-3"; + setConversationState(conversationId, { + filesTabContentViewMode: "rich", + }); + + expect(getConversationState(conversationId).filesTabContentViewMode).toBe( + "rich", + ); + }); + + it("is isolated per conversation", () => { + setConversationState("files-view-convA", { + filesTabContentViewMode: "plain", + }); + setConversationState("files-view-convB", { + filesTabContentViewMode: "rich", + }); + + expect( + getConversationState("files-view-convA").filesTabContentViewMode, + ).toBe("plain"); + expect( + getConversationState("files-view-convB").filesTabContentViewMode, + ).toBe("rich"); + }); + + it("falls back to the 'rich' default when localStorage holds a junk value", () => { + // A corrupted entry (older build with a renamed mode, a hand-edited + // value in devtools, …) must not leak through to the ViewMode-typed + // consumer — the sanitizer drops the bad value so the merged result + // re-applies the typed default. + const conversationId = "files-view-corrupt"; + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.setItem( + key, + JSON.stringify({ filesTabContentViewMode: "fancy" }), + ); + + const state = getConversationState(conversationId); + expect(state.filesTabContentViewMode).toBe("rich"); + }); + }); + + describe("files tab open-state / tree persistence", () => { + it("defaults to an expanded tree and no open files", () => { + const state = getConversationState("files-open-defaults"); + expect(state.filesTabTreeVisible).toBe(true); + expect(state.filesTabOpenPaths).toEqual([]); + expect(state.filesTabSelectedPath).toBeNull(); + }); + + it("round-trips tree visibility and open tabs", () => { + const conversationId = "files-open-roundtrip"; + setConversationState(conversationId, { + filesTabTreeVisible: false, + filesTabOpenPaths: ["README.md", "src/main.ts"], + filesTabSelectedPath: "src/main.ts", + }); + + const state = getConversationState(conversationId); + expect(state.filesTabTreeVisible).toBe(false); + expect(state.filesTabOpenPaths).toEqual(["README.md", "src/main.ts"]); + expect(state.filesTabSelectedPath).toBe("src/main.ts"); + }); + + it("sanitizes corrupt open-state fields", () => { + const conversationId = "files-open-corrupt"; + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.setItem( + key, + JSON.stringify({ + filesTabTreeVisible: "yes", + filesTabOpenPaths: ["ok.ts", 12, "", null], + filesTabSelectedPath: { path: "nope" }, + }), + ); + + const state = getConversationState(conversationId); + expect(state.filesTabTreeVisible).toBe(true); + expect(state.filesTabOpenPaths).toEqual(["ok.ts"]); + expect(state.filesTabSelectedPath).toBeNull(); + }); + }); +}); diff --git a/__tests__/initial-query.test.tsx b/__tests__/initial-query.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f6c56f26ccd2d7b2fb60d6348734126895077bae --- /dev/null +++ b/__tests__/initial-query.test.tsx @@ -0,0 +1,24 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useInitialQueryStore } from "../src/stores/initial-query-store"; + +describe("Initial Query Behavior", () => { + beforeEach(() => { + // Reset the store before each test + useInitialQueryStore.getState().reset(); + }); + + it("should clear initial query when clearInitialPrompt is called", () => { + const { setInitialPrompt, clearInitialPrompt, initialPrompt } = + useInitialQueryStore.getState(); + + // Set up initial query in the store + setInitialPrompt("test query"); + expect(useInitialQueryStore.getState().initialPrompt).toBe("test query"); + + // Clear the initial query + clearInitialPrompt(); + + // Verify initial query is cleared + expect(useInitialQueryStore.getState().initialPrompt).toBeNull(); + }); +}); diff --git a/__tests__/library-entrypoints.test.ts b/__tests__/library-entrypoints.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..88ac7700e44625da62715c5353dbff726c30f8b0 --- /dev/null +++ b/__tests__/library-entrypoints.test.ts @@ -0,0 +1,49 @@ +import * as publicApi from "../src/index"; +import * as browserApi from "../src/components/browser/index"; +import * as conversationApi from "../src/components/conversation/index"; +import * as filesApi from "../src/components/files/index"; +import * as settingsApi from "../src/components/settings/index"; +import * as sidebarApi from "../src/components/sidebar/index"; +import * as terminalApi from "../src/components/terminal/index"; +import { describe, expect, it } from "vitest"; + +describe("library public entrypoints", () => { + it("re-exports the primary library surface from the root entry", () => { + expect(publicApi.ConversationView).toBeTypeOf("function"); + expect(publicApi.ChatPanel).toBeTypeOf("function"); + expect(publicApi.TerminalPanel).toBeTypeOf("function"); + expect(publicApi.BrowserPanel).toBeTypeOf("function"); + expect(publicApi.FileExplorer).toBeTypeOf("function"); + expect(publicApi.SettingsPanel).toBeTypeOf("function"); + expect(publicApi.LLMSettings).toBeTypeOf("function"); + expect(publicApi.Sidebar).toBeTypeOf("function"); + expect(publicApi.ConversationPanel).toBeTypeOf("function"); + expect(publicApi.AgentServerUIProviders).toBeTypeOf("function"); + expect(publicApi.AgentServerUIRoot).toBeTypeOf("function"); + expect(publicApi.AGENT_SERVER_UI_SCOPE_SELECTOR).toBe( + "[data-agent-server-ui]", + ); + expect(publicApi.AGENT_SERVER_UI_DEFAULT_THEME).toBe("dark"); + }); + + it("keeps each component-domain barrel importable", () => { + expect(conversationApi.ConversationView).toBeTypeOf("function"); + expect(conversationApi.ChatPanel).toBeTypeOf("function"); + expect(browserApi.BrowserPanel).toBeTypeOf("function"); + expect(terminalApi.TerminalPanel).toBeTypeOf("function"); + expect(filesApi.FileExplorer).toBeTypeOf("function"); + expect(settingsApi.SettingsPanel).toBeTypeOf("function"); + expect(settingsApi.AppSettings).toBeTypeOf("function"); + expect(settingsApi.LLMSettings).toBeTypeOf("function"); + expect(settingsApi.MCPSettings).toBeTypeOf("function"); + expect(settingsApi.SecretsSettings).toBeTypeOf("function"); + expect(sidebarApi.Sidebar).toBeTypeOf("function"); + expect(sidebarApi.ConversationPanel).toBeTypeOf("function"); + }); + + it("no longer exposes the removed AgentServerSettings entry", () => { + expect( + (settingsApi as Record).AgentServerSettings, + ).toBeUndefined(); + }); +}); diff --git a/__tests__/package-library.test.ts b/__tests__/package-library.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4013e24e1d77bfa7efd7cb3b3ac7384c0d339e5 --- /dev/null +++ b/__tests__/package-library.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment node +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const packageJson = JSON.parse( + readFileSync(resolve(__dirname, "../package.json"), "utf8"), +) as { + name: string; + main: string; + module: string; + types: string; + exports: Record; + scripts: Record; + dependencies?: Record; + devDependencies?: Record; +}; + +const EXACT_SEMVER_PATTERN = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +describe("package library metadata", () => { + const ALLOWED_STACK_PIN_DEPS = new Set([ + "@openhands/extensions", + "@openhands/typescript-client", + ]); + + it("publishes the agent-canvas package entrypoints", () => { + expect(packageJson.name).toBe("@openhands/agent-canvas"); + expect(packageJson.main).toBe("./dist/index.cjs"); + expect(packageJson.module).toBe("./dist/index.js"); + expect(packageJson.types).toBe("./dist/index.d.ts"); + expect(packageJson.exports).toMatchObject({ + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + require: "./dist/index.cjs", + }, + "./conversation": { + types: "./dist/components/conversation/index.d.ts", + import: "./dist/components/conversation/index.js", + require: "./dist/components/conversation/index.cjs", + }, + "./settings": { + types: "./dist/components/settings/index.d.ts", + import: "./dist/components/settings/index.js", + require: "./dist/components/settings/index.cjs", + }, + "./terminal": { + types: "./dist/components/terminal/index.d.ts", + import: "./dist/components/terminal/index.js", + require: "./dist/components/terminal/index.cjs", + }, + "./i18n": { + types: "./dist/i18n/index.d.ts", + import: "./dist/i18n/index.js", + require: "./dist/i18n/index.cjs", + }, + }); + }); + + // Git dependencies break `npm install -g` because npm clones the repo and + // runs the prepare script without devDependencies. All packages should be + // referenced from a registry. @openhands/extensions is allowed until it is + // published to npm; @openhands/typescript-client is temporarily allowed while + // this stacked PR waits for the subscription client branch to merge/release. + // TODO(#917): remove @openhands/typescript-client exemption once + // OpenHands/typescript-client#178 merges and publishes to npm. + it("does not use git dependencies except approved stack pins", () => { + const GIT_DEP_PATTERN = + /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/; + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + const violations = Object.entries(allDeps) + .filter( + ([name, version]) => + GIT_DEP_PATTERN.test(version) && !ALLOWED_STACK_PIN_DEPS.has(name), + ) + .map(([name, version]) => `${name}: ${version}`); + + expect(violations).toEqual([]); + }); + + it("pins direct dependency versions exactly", () => { + const allDepsBySection = { + dependencies: packageJson.dependencies, + devDependencies: packageJson.devDependencies, + }; + + const violations = Object.entries(allDepsBySection).flatMap( + ([section, dependencies]) => + Object.entries(dependencies ?? {}) + .filter( + ([name, version]) => + !EXACT_SEMVER_PATTERN.test(version) && + !ALLOWED_STACK_PIN_DEPS.has(name), + ) + .map(([name, version]) => `${section}.${name}: ${version}`), + ); + + expect(violations).toEqual([]); + }); + + it("prints startup guidance only for global installs", () => { + const runPostinstall = (isGlobal: boolean) => { + const env = { ...process.env }; + if (isGlobal) { + env.npm_config_global = "true"; + } else { + delete env.npm_config_global; + } + + return spawnSync(packageJson.scripts.postinstall, { + encoding: "utf8", + env, + shell: true, + }); + }; + + const dependencyInstall = runPostinstall(false); + const globalInstall = runPostinstall(true); + + expect(dependencyInstall.status).toBe(0); + expect(dependencyInstall.stdout).toBe(""); + expect(globalInstall.status).toBe(0); + expect(globalInstall.stdout).toContain("To start Agent Canvas, run:"); + }); + + it("ships runtime logger dependencies for the published CLI", () => { + expect(packageJson.dependencies).toMatchObject({ + winston: "3.19.0", + "winston-daily-rotate-file": "5.0.0", + }); + expect(packageJson.devDependencies?.winston).toBeUndefined(); + expect( + packageJson.devDependencies?.["winston-daily-rotate-file"], + ).toBeUndefined(); + }); + + it("uses local dev commands without Docker", () => { + expect(packageJson.scripts.dev).toBe( + "node --env-file-if-exists=.env scripts/dev-with-automation.mjs", + ); + expect(packageJson.scripts["dev:static"]).toBe( + "node --env-file-if-exists=.env scripts/dev-static.mjs", + ); + expect(packageJson.scripts["dev:minimal"]).toBe( + "node --env-file-if-exists=.env scripts/dev-safe.mjs", + ); + expect(packageJson.scripts["dev:docker"]).toBeUndefined(); + expect(packageJson.scripts["dev:docker:dynamic"]).toBeUndefined(); + expect(packageJson.scripts["dev:dangerously-dockerless"]).toBeUndefined(); + }); +}); diff --git a/__tests__/query-client-config.behavior.test.ts b/__tests__/query-client-config.behavior.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1eab7b109d4daf764d6c073c73c264d49718f44 --- /dev/null +++ b/__tests__/query-client-config.behavior.test.ts @@ -0,0 +1,522 @@ +import { QueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentServerQueryClient } from "#/query-client-config"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import { + __resetHealthStoreForTests, + getBackendHealthEntry, + recordBackendFailure, +} from "#/api/backend-registry/health-store"; +import * as ToastHandlers from "#/utils/custom-toast-handlers"; + +interface ErrorOptions { + directStatus?: boolean; + message?: string; + status?: number; + url?: string; +} + +function createAxiosError({ + directStatus = false, + message = "Request failed", + status, + url, +}: ErrorOptions = {}) { + const error = new AxiosError( + message, + "ERR_BAD_REQUEST", + url ? ({ url } as never) : undefined, + undefined, + !directStatus && status !== undefined ? ({ status } as never) : undefined, + ); + error.status = directStatus ? status : undefined; + return error; +} + +function createBackend(overrides: Partial = {}): Backend { + return { + id: "local-backend", + name: "Local Backend", + host: "http://localhost:3000", + apiKey: "test-key", + kind: "local", + ...overrides, + }; +} + +function activateBackend(backend: Backend) { + const selection = { backendId: backend.id, orgId: null }; + window.localStorage.setItem("openhands-backends", JSON.stringify([backend])); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify(selection), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify(selection), + ); + __resetActiveStoreForTests(); +} + +function executeFailingQuery( + client: QueryClient, + error: unknown, + { + meta, + queryKey = ["behavior", "failure"], + }: { + meta?: Record; + queryKey?: readonly unknown[]; + } = {}, +) { + return client.fetchQuery({ + queryKey, + queryFn: async () => { + throw error; + }, + meta, + retry: false, + }); +} + +function executeFailingMutation( + client: QueryClient, + error: unknown, + meta?: Record, +) { + const mutation = client.getMutationCache().build(client, { + mutationFn: async () => { + throw error; + }, + meta, + retry: false, + }); + return mutation.execute(undefined); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + window.localStorage.clear(); + window.sessionStorage.clear(); + delete (window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient }) + .__OH_QUERY_CLIENT__; + __resetActiveStoreForTests(); + __resetHealthStoreForTests(); +}); + +describe("query client behavior", () => { + it("records successful queries for string backend identifiers only", async () => { + const client = createAgentServerQueryClient(); + const malformedBackendId = 42; + const malformedMeta = { backendId: malformedBackendId } as unknown as { + backendId: string; + }; + recordBackendFailure("backend-one", new Error("offline")); + recordBackendFailure( + malformedBackendId as unknown as string, + new Error("offline"), + ); + + await client.fetchQuery({ + queryKey: ["health", "backend-one"], + queryFn: async () => "healthy", + meta: { backendId: "backend-one" }, + }); + await client.fetchQuery({ + queryKey: ["health", "backend-two"], + queryFn: async () => "healthy", + meta: malformedMeta, + }); + await client.fetchQuery({ + queryKey: ["health", "unattributed"], + queryFn: async () => "healthy", + }); + + expect(getBackendHealthEntry("backend-one")).toBeNull(); + expect( + getBackendHealthEntry(malformedBackendId as unknown as string), + ).not.toBeNull(); + }); + + it.each([ + { queryKey: ["settings"], description: "an unrelated query" }, + { queryKey: ["user", "profile"], description: "another user query" }, + { + queryKey: ["settings", "authenticated"], + description: "a non-user query ending in authenticated", + }, + ])( + "invalidates authentication after a 401 from $description", + async ({ queryKey }) => { + const client = createAgentServerQueryClient(); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockResolvedValue(); + const error = createAxiosError({ status: 401 }); + + await expect( + executeFailingQuery(client, error, { + meta: { disableToast: true }, + queryKey, + }), + ).rejects.toBe(error); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["user", "authenticated"], + }); + }, + ); + + it("does not recursively invalidate authentication when that query fails", async () => { + const client = createAgentServerQueryClient(); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockResolvedValue(); + const error = createAxiosError({ status: 401 }); + + await expect( + executeFailingQuery(client, error, { + meta: { disableToast: true }, + queryKey: ["user", "authenticated"], + }), + ).rejects.toBe(error); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("recognizes a direct Axios status when a mutation receives a 401", async () => { + const client = createAgentServerQueryClient(); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockResolvedValue(); + const error = createAxiosError({ directStatus: true, status: 401 }); + + await expect( + executeFailingMutation(client, error, { disableToast: true }), + ).rejects.toBe(error); + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["user", "authenticated"], + }); + }); + + it("does not invalidate authentication for non-401 failures", async () => { + const client = createAgentServerQueryClient(); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockResolvedValue(); + const error = createAxiosError({ status: 500 }); + + await expect( + executeFailingQuery(client, error, { meta: { disableToast: true } }), + ).rejects.toBe(error); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("preserves null mutation failures without invalidating authentication", async () => { + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const invalidateQueries = vi + .spyOn(client, "invalidateQueries") + .mockResolvedValue(); + + await expect(executeFailingMutation(client, null)).rejects.toBeNull(); + + expect(invalidateQueries).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith(expect.any(String)); + }); + + it.each([ + { + directStatus: false, + url: undefined, + description: "has no request URL", + }, + { + directStatus: false, + url: "https://cloud.example/api/conversations", + description: "targets the active cloud host", + }, + { + directStatus: true, + url: "https://cloud.example/api/settings", + description: "reports 401 directly for the active cloud host", + }, + ])( + "suppresses a cloud authentication toast when the request $description", + async ({ directStatus, url }) => { + activateBackend( + createBackend({ + id: "cloud-backend", + name: "Cloud Backend", + host: "https://cloud.example///", + kind: "cloud", + }), + ); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = createAxiosError({ + directStatus, + message: "Cloud authentication failed", + status: 401, + url, + }); + + await expect( + executeFailingQuery(client, error, { + queryKey: ["cloud", url ?? "missing-url"], + }), + ).rejects.toBe(error); + + expect(toast).not.toHaveBeenCalled(); + }, + ); + + it("shows a non-authentication error from the active cloud host", async () => { + activateBackend( + createBackend({ + id: "cloud-backend", + host: "https://cloud.example", + kind: "cloud", + }), + ); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = createAxiosError({ + message: "Cloud service unavailable", + status: 503, + url: "https://cloud.example/api/settings", + }); + + await expect( + executeFailingQuery(client, error, { + queryKey: ["cloud", "service-unavailable"], + }), + ).rejects.toBe(error); + + expect(toast).toHaveBeenCalledWith("Cloud service unavailable"); + }); + + it("shows a 401 toast when a cloud request targets another host", async () => { + activateBackend( + createBackend({ + id: "cloud-backend", + host: "https://cloud.example", + kind: "cloud", + }), + ); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = createAxiosError({ + message: "Foreign host authentication failed", + status: 401, + url: "https://different.example/api", + }); + + await expect( + executeFailingQuery(client, error, { + queryKey: ["cloud", "foreign-host"], + }), + ).rejects.toBe(error); + + expect(toast).toHaveBeenCalledWith("Foreign host authentication failed"); + }); + + it("shows a 401 toast for an active local backend", async () => { + activateBackend(createBackend()); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = createAxiosError({ + message: "Local authentication failed", + status: 401, + }); + + await expect( + executeFailingQuery(client, error, { + queryKey: ["local", "authentication"], + }), + ).rejects.toBe(error); + + expect(toast).toHaveBeenCalledWith("Local authentication failed"); + }); + + it("deduplicates query toasts until the cooldown expires", async () => { + vi.useFakeTimers(); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const first = new AxiosError("Repeated query failure"); + const second = new AxiosError("Repeated query failure"); + + await expect( + executeFailingQuery(client, first, { + queryKey: ["dedupe", "first"], + }), + ).rejects.toBe(first); + await expect( + executeFailingQuery(client, second, { + queryKey: ["dedupe", "second"], + }), + ).rejects.toBe(second); + expect(toast).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3000); + const afterCooldown = new AxiosError("Repeated query failure"); + await expect( + executeFailingQuery(client, afterCooldown, { + queryKey: ["dedupe", "after-cooldown"], + }), + ).rejects.toBe(afterCooldown); + + expect(toast).toHaveBeenCalledTimes(2); + }); + + it("uses the translated generic query error when no message is available", async () => { + vi.useFakeTimers(); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const first = {}; + const duplicate = {}; + + await expect( + executeFailingQuery(client, first, { + queryKey: ["generic", "first"], + }), + ).rejects.toBe(first); + await expect( + executeFailingQuery(client, duplicate, { + queryKey: ["generic", "duplicate"], + }), + ).rejects.toBe(duplicate); + + expect(toast).toHaveBeenCalledWith(expect.any(String)); + expect(toast).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3000); + const afterCooldown = {}; + await expect( + executeFailingQuery(client, afterCooldown, { + queryKey: ["generic", "after-cooldown"], + }), + ).rejects.toBe(afterCooldown); + + expect(toast).toHaveBeenCalledTimes(2); + }); + + it("honors mutation toast metadata", async () => { + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const visible = new AxiosError("Visible mutation failure"); + const suppressed = new AxiosError("Suppressed mutation failure"); + + await expect(executeFailingMutation(client, visible)).rejects.toBe(visible); + await expect( + executeFailingMutation(client, suppressed, { disableToast: true }), + ).rejects.toBe(suppressed); + + expect(toast).toHaveBeenCalledOnce(); + expect(toast).toHaveBeenCalledWith("Visible mutation failure"); + }); + + it("suppresses matching cloud-auth mutation errors", async () => { + activateBackend( + createBackend({ + id: "cloud-backend", + host: "https://cloud.example", + kind: "cloud", + }), + ); + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = createAxiosError({ + message: "Cloud mutation authentication failed", + status: 401, + url: "https://cloud.example/api/settings", + }); + + await expect(executeFailingMutation(client, error)).rejects.toBe(error); + + expect(toast).not.toHaveBeenCalled(); + }); + + it("uses the translated generic mutation error when no message is available", async () => { + const toast = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + const error = {}; + + await expect(executeFailingMutation(client, error)).rejects.toBe(error); + + expect(toast).toHaveBeenCalledWith(expect.any(String)); + }); +}); + +describe("query client selection and proxy behavior", () => { + it("creates one default client and exposes it in development", async () => { + vi.resetModules(); + const config = await import("#/query-client-config"); + + const first = config.getDefaultQueryClient(); + const second = config.getDefaultQueryClient(); + + expect(first).toBeInstanceOf(QueryClient); + expect(second).toBe(first); + expect(config.getQueryClient()).toBe(first); + expect( + (window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient }) + .__OH_QUERY_CLIENT__, + ).toBe(first); + }); + + it("selects custom clients and forwards proxy reads, calls, and writes", async () => { + vi.resetModules(); + const config = await import("#/query-client-config"); + const custom = new QueryClient(); + + expect(config.setQueryClient(custom)).toBe(custom); + expect(config.getQueryClient()).toBe(custom); + + config.queryClient.setQueryData(["proxy", "value"], "forwarded"); + expect(custom.getQueryData(["proxy", "value"])).toBe("forwarded"); + + const extendedProxy = config.queryClient as QueryClient & { + marker?: string; + }; + const extendedClient = custom as QueryClient & { marker?: string }; + extendedProxy.marker = "proxy-write"; + expect(extendedProxy.marker).toBe("proxy-write"); + expect(extendedClient.marker).toBe("proxy-write"); + + expect(config.setQueryClient(undefined)).toBe( + config.getDefaultQueryClient(), + ); + expect(config.setQueryClient(null)).toBe(config.getDefaultQueryClient()); + }); + + it.each([ + { mockApi: "true", expectedExposure: true }, + { mockApi: "false", expectedExposure: false }, + ])( + "sets window exposure to $expectedExposure outside development when VITE_MOCK_API is $mockApi", + async ({ expectedExposure, mockApi }) => { + vi.stubEnv("DEV", false); + vi.stubEnv("VITE_MOCK_API", mockApi); + vi.resetModules(); + const config = await import("#/query-client-config"); + + const client = config.getDefaultQueryClient(); + const exposed = ( + window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient } + ).__OH_QUERY_CLIENT__; + + if (expectedExposure) { + expect(exposed).toBe(client); + } else { + expect(exposed).toBeUndefined(); + } + }, + ); +}); diff --git a/__tests__/query-client-config.test.ts b/__tests__/query-client-config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..743adce6ecfe0ac7b324d24a986fc228bd5e0eaa --- /dev/null +++ b/__tests__/query-client-config.test.ts @@ -0,0 +1,92 @@ +import { AxiosError } from "axios"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentServerQueryClient } from "#/query-client-config"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; +import * as ToastHandlers from "#/utils/custom-toast-handlers"; + +afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + __resetActiveStoreForTests(); + vi.restoreAllMocks(); +}); + +describe("createAgentServerQueryClient", () => { + it("does not show a toast when query meta disables toasts", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + + await expect( + client.fetchQuery({ + queryKey: ["config", "suppressed"], + queryFn: async () => { + throw new AxiosError("suppressed query error"); + }, + meta: { disableToast: true }, + retry: false, + }), + ).rejects.toThrow("suppressed query error"); + + expect(toastSpy).not.toHaveBeenCalled(); + }); + + it("shows a toast when query meta does not disable toasts", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const client = createAgentServerQueryClient(); + + await expect( + client.fetchQuery({ + queryKey: ["config", "toast"], + queryFn: async () => { + throw new AxiosError("query error with toast"); + }, + retry: false, + }), + ).rejects.toThrow("query error with toast"); + + expect(toastSpy).toHaveBeenCalledWith("query error with toast"); + }); + + it("does not show raw 401 toasts while the active cloud backend is logged out", async () => { + const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); + const backend = { + id: "cloud-expired", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "expired-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([backend]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: backend.id, orgId: null }), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: backend.id, orgId: null }), + ); + __resetActiveStoreForTests(); + const client = createAgentServerQueryClient(); + + await expect( + client.fetchQuery({ + queryKey: ["cloud", "logged-out"], + queryFn: async () => { + throw new AxiosError( + "Request failed with status code 401", + "ERR_BAD_REQUEST", + undefined, + undefined, + { status: 401 } as never, + ); + }, + retry: false, + }), + ).rejects.toThrow("Request failed with status code 401"); + + expect(toastSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aaa6f8fef90a2a5e1c7c7230bc3db91d8fc7ea7b --- /dev/null +++ b/__tests__/root.test.tsx @@ -0,0 +1,926 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRoutesStub } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { http, HttpResponse } from "msw"; +import App, { links } from "#/root"; +import { server } from "#/mocks/node"; +import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; +import { LOCKED_CLOUD_BACKEND_ID } from "#/api/backend-registry/default-backend"; +import { __resetHealthStoreForTests } from "#/api/backend-registry/health-store"; +import { + BACKEND_HEALTH_STORAGE_KEY, + MAX_CONSECUTIVE_FAILURES, +} from "#/api/backend-registry/health-storage"; +import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; + +const TRANSLATIONS: Record = { + BACKEND$MANAGE_TITLE: "Manage backends", + BACKEND$RECONNECT_CLOUD_TITLE: "Reconnect to Cloud", + BACKEND$RECONNECT_CLOUD: "Reconnect to Cloud", + BACKEND$MANAGE_EMPTY: "No backends yet.", + BACKEND$ADD: "+ Add Backend", + BACKEND$LOG_BACK_IN: "Log back in", + BACKEND$LOGGED_OUT: "Logged out", + BACKEND$KIND_LOCAL: "Local", + BACKEND$KIND_CLOUD: "Cloud", + BACKEND$EDIT: "Edit", + BACKEND$REMOVE: "Remove", + HOME$DONE: "Done", +}; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + let value = TRANSLATIONS[key] ?? key; + for (const [optionKey, optionValue] of Object.entries(options ?? {})) { + value = value.replaceAll(`{{${optionKey}}}`, String(optionValue)); + } + return value; + }, + }), +})); + +vi.mock("#/components/features/onboarding/onboarding-modal", async () => { + const React = await import("react"); + const { useNavigation } = await import("#/context/navigation-context"); + + return { + OnboardingModal: ({ onClose }: { onClose: () => void }) => { + const { navigate } = useNavigation(); + return React.createElement( + "div", + { "data-testid": "onboarding-modal" }, + React.createElement("div", { + "data-testid": "onboarding-step-check-backend", + }), + React.createElement( + "button", + { + type: "button", + "data-testid": "mock-onboarding-launch", + onClick: () => { + navigate("/conversations/mock-conversation"); + onClose(); + }, + }, + "Launch conversation", + ), + ); + }, + }; +}); + +const ORIGINAL_LOCATION = window.location; + +const RouterStub = createRoutesStub([ + { + Component: App, + path: "/", + children: [ + { + Component: () =>
app outlet
, + path: "/", + }, + { + Component: () => ( +
conversation outlet
+ ), + path: "/conversations/:conversationId", + }, + ], + }, +]); + +const renderApp = (initialEntries: string[] = ["/"]) => + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +const COOKIE_DEPLOYMENT_ORIGIN = "https://pr-254.staging.openhands.dev"; + +/** + * Simulate an OHE-hosted Canvas: served from the locked Cloud host itself, so + * the single locked backend authenticates with the main-app session cookie. + * Returns the `window.location.assign` spy that observes login redirects. + */ +function mockLockedCookieDeployment() { + const assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...ORIGINAL_LOCATION, + origin: COOKIE_DEPLOYMENT_ORIGIN, + hostname: "pr-254.staging.openhands.dev", + pathname: "/canvas", + search: "", + hash: "", + assign, + }, + }); + vi.stubEnv("VITE_LOCK_TO_CLOUD", COOKIE_DEPLOYMENT_ORIGIN); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + __resetActiveStoreForTests(); + return assign; +} + +describe("App root agent-server availability guard", () => { + beforeEach(() => { + window.localStorage.clear(); + __resetHealthStoreForTests(); + vi.unstubAllEnvs(); + delete (window as unknown as Record) + .__AGENT_CANVAS_AUTH_REQUIRED__; + delete (window as unknown as Record) + .__AGENT_CANVAS_LOCK_TO_CLOUD__; + ( + window as unknown as Record + ).__AGENT_CANVAS_SESSION_API_KEY__ = "test-session-key"; + __resetActiveStoreForTests(); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: ORIGINAL_LOCATION, + }); + }); + + it("shows first-run onboarding before the auth gate when public mode has no backend key", async () => { + vi.stubEnv("VITE_AUTH_REQUIRED", "true"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + await screen.findByTestId("onboarding-step-check-backend"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("api-key-entry-screen"), + ).not.toBeInTheDocument(); + }); + + it("shows first-run onboarding before the recovery modal when no backend is configured", async () => { + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-server-onboarding-screen"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + }); + + it("lets root-level onboarding navigate to the launched conversation before closing", async () => { + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), + ), + ); + + renderApp(["/"]); + + fireEvent.click(await screen.findByTestId("mock-onboarding-launch")); + + await waitFor(() => { + expect(screen.getByTestId("conversation-outlet")).toBeInTheDocument(); + }); + expect(window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY)).toBe( + "1", + ); + expect( + screen.queryByTestId("first-run-onboarding-screen"), + ).not.toBeInTheDocument(); + }); + + it("shows first-run onboarding before the recovery modal when locked to Cloud with no backend", async () => { + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-server-onboarding-screen"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + }); + + it("shows first-run onboarding when locked to Cloud even if a session API key is baked in", async () => { + // Reproduces Hiep's report on PR #1389: a pre-built bundle with a baked-in + // VITE_SESSION_API_KEY plus --lock-to-cloud used to seed a disconnected + // Local backend, which skipped onboarding and landed on the Manage Backends + // recovery modal. Locked mode must not seed a Local backend, so onboarding + // still owns the first-run Cloud login. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", "baked-session-key"); + ( + window as unknown as Record + ).__AGENT_CANVAS_SESSION_API_KEY__ = "baked-session-key"; + window.localStorage.clear(); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-server-onboarding-screen"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + // No Local backend should have been seeded into the registry. + expect(window.localStorage.getItem("openhands-backends")).toBeNull(); + }); + + it("shows first-run onboarding when locked to Cloud with a stale persisted Local backend", async () => { + // A Local backend persisted from a previous non-locked session must not + // bypass onboarding once the deployment is locked to Cloud. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([ + { + id: "default-local", + name: "Local", + host: "http://127.0.0.1:8000", + apiKey: "stale-key", + kind: "local", + }, + ]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: "default-local", orgId: null }), + ); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + }); + + it("forces first-run onboarding in locked mode even when a stale Local backend reports a configured LLM", async () => { + // Critical regression for PR #1389 review: in locked-to-Cloud mode the + // stale Local backend must not bypass onboarding, even when it happens + // to report a configured LLM. The user must be routed through the Cloud + // login / replacement flow instead. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([ + { + id: "user-added-local", + name: "My agent-server", + host: "http://127.0.0.1:8000", + apiKey: "stale-key", + kind: "local", + }, + ]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: "user-added-local", orgId: null }), + ); + __resetActiveStoreForTests(); + server.use( + http.get("*/api/settings", () => + HttpResponse.json({ + llm_api_key_is_set: true, + agent_settings: { + llm: { model: "openai/gpt-5.5", api_key: "stored" }, + }, + }), + ), + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + // Backend readiness must NOT persist onboarding completion. + expect( + window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), + ).toBeNull(); + }); + + it("forces first-run onboarding in locked mode when a Cloud backend points at a different host with a configured LLM", async () => { + // Companion to the stale-Local test: a Cloud backend on a *different* + // host than the locked Cloud host must also be forced through + // onboarding, even if it reports a configured LLM. `kind === "cloud"` + // alone is not enough — the host must match the locked host. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + const otherCloud = { + id: "other-cloud", + name: "Other Cloud", + host: "https://other-cloud.example.com", + apiKey: "other-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([otherCloud]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: otherCloud.id, orgId: null }), + ); + __resetActiveStoreForTests(); + server.use( + http.get("*/api/settings", () => + HttpResponse.json({ + llm_api_key_set: true, + agent_settings: { + llm: { model: "openai/gpt-5.5" }, + }, + }), + ), + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), + ).toBeNull(); + }); + + it("shows first-run onboarding when locked to Cloud even if onboarding was previously completed", async () => { + // Reproduces hieptl's report on PR #1389: the user had previously + // completed onboarding in a non-locked session (so the + // `openhands-onboarded` localStorage flag is set), then relaunched the + // static server with --lock-to-cloud. The stale completion flag used to + // suppress first-run onboarding, so the app fell through to the Manage + // Backends recovery modal ("Add Backend") instead of going straight to + // Cloud login. In locked-to-Cloud mode the completion flag must not + // bypass onboarding when the active backend is not a connected Cloud + // backend. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect( + screen.queryByTestId("agent-server-onboarding-screen"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + }); + + it("shows the auth gate after onboarding was already completed", async () => { + vi.stubEnv("VITE_AUTH_REQUIRED", "true"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + window.localStorage.clear(); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect(screen.getByTestId("api-key-entry-screen")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); + }); + + it("shows the manage-backends modal when the connected server reports an old version", async () => { + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.27.1" }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("shows the manage-backends modal when the server omits a version field", async () => { + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0 }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("shows the manage-backends modal when the backend is unreachable", async () => { + let serverInfoRequests = 0; + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + + // Use "*" prefix to match both relative paths and absolute URLs (e.g., + // http://127.0.0.1:8000/server_info) when VITE_BACKEND_BASE_URL is configured. + server.use( + http.get("*/server_info", () => { + serverInfoRequests += 1; + return HttpResponse.error(); + }), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + + // The onboarding placeholder now hosts the Manage Backends modal + // directly so the user can edit/add a backend immediately. The + // modal additionally probes /server_info per registered backend + // for its status dot + version label, so the request count is + // bounded but greater than the single config probe. + await waitFor(() => { + expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); + }); + expect(serverInfoRequests).toBeGreaterThanOrEqual(1); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => { + const cloudBackend = { + id: "cloud-expired", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "expired-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([cloudBackend]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + __resetActiveStoreForTests(); + server.use( + http.get("https://app.all-hands.dev/api/keys/current", () => + HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); + expect(screen.getByText("Logged out")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Log back in" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("shows locked Cloud reconnect recovery without add-backend controls", async () => { + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + const cloudBackend = { + id: "cloud-expired", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "expired-token", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([cloudBackend]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + window.sessionStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: cloudBackend.id, orgId: null }), + ); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + __resetActiveStoreForTests(); + server.use( + http.get("https://app.all-hands.dev/api/keys/current", () => + HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Reconnect to Cloud" }), + ).toBeInTheDocument(); + }); + expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument(); + expect( + screen.getByTestId("manage-backends-reconnect-cloud-login-button"), + ).toHaveTextContent("Reconnect to Cloud"); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("renders the routed page when the agent server is reachable", async () => { + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + + renderApp(["/"]); + + await waitFor(() => { + expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId("agent-server-onboarding-screen"), + ).not.toBeInTheDocument(); + }); + + it("shows first-run onboarding for the launcher-seeded default-local backend even when the agent-server reports a configured LLM", async () => { + // Regression for mock-llm-onboarding-regressions.spec.ts:16 + // ("keeps the modal open on backdrop click and Escape") and + // mock-llm-auth-modes.spec.ts:57 ("reaches the onboarding modal + // without pre-seeded localStorage"). The shared mock-LLM + // agent-server retains a previously-configured LLM across browser + // sessions, so a genuinely fresh browser install (launcher-seeded + // default-local backend, no `openhands-onboarded` flag) must NOT + // have onboarding auto-marked complete by backend readiness. + vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); + vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key"); + // The launcher-seeded default-local backend (id + // SEEDED_DEFAULT_BACKEND_ID) is created from these env stubs by + // readStoredBackends(). + __resetActiveStoreForTests(); + server.use( + http.get("*/api/settings", () => + HttpResponse.json({ + llm_api_key_is_set: true, + agent_settings: { + llm: { model: "openai/gpt-5.5", api_key: "stored" }, + }, + }), + ), + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), + ), + ); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + + expect( + window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), + ).toBeNull(); + }); + + it("renders Cloud login directly for a fresh locked-to-Cloud first run", async () => { + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect( + screen.getByTestId("first-run-onboarding-screen"), + ).toBeInTheDocument(); + }); + + expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); + expect(screen.getByTestId("add-backend-cloud-title")).toBeVisible(); + expect(screen.getByTestId("add-backend-login-button")).toBeVisible(); + expect( + screen.queryByTestId("onboarding-step-check-backend"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("onboarding-progress-bar"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("add-backend-close")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("add-backend-advanced-toggle"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("redirects unauthenticated locked-cookie deployments to main app login", async () => { + const assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...ORIGINAL_LOCATION, + origin: "https://pr-254.staging.openhands.dev", + hostname: "pr-254.staging.openhands.dev", + pathname: "/canvas", + search: "?tab=home", + hash: "#top", + assign, + }, + }); + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://pr-254.staging.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + server.use( + http.post("*/api/authenticate", () => + HttpResponse.json({ error: "unauthenticated" }, { status: 401 }), + ), + ); + __resetActiveStoreForTests(); + + renderApp(["/"]); + + await waitFor(() => { + expect(assign).toHaveBeenCalledWith( + "/login?returnTo=%2Fcanvas%3Ftab%3Dhome%23top", + ); + }); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("redirects to main app login when the cookie session expires after the Canvas loaded", async () => { + // Arrange: the session is valid at load, then expires — the Cloud probe + // starts returning 401 and the next main-app auth check confirms it. + const assign = mockLockedCookieDeployment(); + let sessionExpired = false; + server.use( + http.post("*/api/authenticate", () => + sessionExpired + ? HttpResponse.json({ error: "unauthenticated" }, { status: 401 }) + : HttpResponse.json({ ok: true }), + ), + http.get(`${COOKIE_DEPLOYMENT_ORIGIN}/api/organizations`, () => { + sessionExpired = true; + return HttpResponse.json( + { detail: "Not authenticated" }, + { status: 401 }, + ); + }), + ); + + // Act + renderApp(["/"]); + + // Assert: main-app login, not the device-flow recovery modal. + await waitFor(() => { + expect(assign).toHaveBeenCalledWith("/login?returnTo=%2Fcanvas"); + }); + expect( + screen.queryByTestId("manage-backends-modal"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); + }); + + it("keeps a valid cookie session on the app when a stale logged-out health entry is persisted", async () => { + // Arrange: a previous visit left the locked backend persisted as disabled + // and "Logged out", but the user has since logged back in on the main app. + const assign = mockLockedCookieDeployment(); + window.localStorage.setItem( + BACKEND_HEALTH_STORAGE_KEY, + JSON.stringify({ + [LOCKED_CLOUD_BACKEND_ID]: { + consecutiveFailures: MAX_CONSECUTIVE_FAILURES, + lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR, + lastFailureAt: 1, + disabled: true, + }, + }), + ); + __resetHealthStoreForTests(); + server.use( + http.get(`${COOKIE_DEPLOYMENT_ORIGIN}/api/organizations`, () => + HttpResponse.json({ items: [], current_org_id: null }), + ), + ); + + // Act + renderApp(["/"]); + + // Assert: the stale health verdict must not bounce a valid session to + // /login (which would loop back via returnTo); the app renders instead. + await waitFor(() => { + expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); + }); + expect(assign).not.toHaveBeenCalled(); + }); + + it("hides first-run onboarding immediately after Cloud login completes in locked-to-Cloud mode (no flicker)", async () => { + // Regression for hieptl's flicker report on PR #1389: after Cloud + // login succeeds in locked-to-Cloud mode, the onboarding modal's + // onClose marks onboarding complete. The root first-run gate must + // honor that completion IMMEDIATELY — without waiting for the Cloud + // settings probe to confirm a configured LLM — so the first-run + // screen disappears and the routed app renders, rather than the + // modal flickering back via OnboardingHost. This test simulates the + // post-login state (active locked Cloud backend + completion flag + // set by the modal's onClose) with the Cloud settings probe + // reporting NO configured LLM, which is exactly the window where + // the old LLM-readiness gate kept the first-run screen mounted and + // caused the reopen. + vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); + vi.stubEnv("VITE_SESSION_API_KEY", ""); + delete (window as unknown as Record) + .__AGENT_CANVAS_SESSION_API_KEY__; + const lockedCloud = { + id: "locked-cloud", + name: "OpenHands Cloud", + host: "https://app.all-hands.dev", + apiKey: "cloud-session-key", + kind: "cloud", + }; + window.localStorage.setItem( + "openhands-backends", + JSON.stringify([lockedCloud]), + ); + window.localStorage.setItem( + "openhands-active-backend", + JSON.stringify({ backendId: lockedCloud.id, orgId: null }), + ); + // The onboarding modal's onClose (markCompleted) sets this right + // after Cloud login succeeds — before the Cloud settings probe + // resolves. Seed it to reproduce the post-login moment. + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + __resetActiveStoreForTests(); + // Cloud settings probe reports no configured LLM. The completed + // onboarding flag should still hide first-run onboarding once the + // locked Cloud backend is active. + server.use( + http.get("https://app.all-hands.dev/api/v1/settings", () => + HttpResponse.json({ llm_api_key_set: false }), + ), + http.get("https://app.all-hands.dev/api/keys/current", () => + HttpResponse.json({ org_id: "org-1" }), + ), + ); + + renderApp(["/"]); + + // The first-run onboarding screen must NOT be mounted (no reopen), + // and the routed app must render instead. + await waitFor(() => { + expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); + }); + expect( + screen.queryByTestId("first-run-onboarding-screen"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); + }); +}); + +describe("App root document links", () => { + it("declares the SVG favicon used by the browser tab", () => { + // Act + const documentLinks = links(); + + // Assert + expect(documentLinks).toContainEqual({ + rel: "icon", + type: "image/svg+xml", + href: "/favicon.svg", + }); + }); + + it("prefixes document links when Canvas is mounted under a base path", () => { + // Arrange + vi.stubEnv("VITE_BASE_PATH", "/canvas"); + + // Act + const documentLinks = links(); + + // Assert + expect(documentLinks).toContainEqual({ + rel: "icon", + type: "image/svg+xml", + href: "/canvas/favicon.svg", + }); + + vi.unstubAllEnvs(); + }); +}); diff --git a/__tests__/router.md b/__tests__/router.md new file mode 100644 index 0000000000000000000000000000000000000000..b44490d0cf1cacb9830471313f0db9dc11740e38 --- /dev/null +++ b/__tests__/router.md @@ -0,0 +1,227 @@ +# Testing with React Router + +## Overview + +React Router components and hooks require a routing context to function. In tests, we need to provide this context while maintaining control over the routing state. + +This guide covers the two main approaches used in the OpenHands frontend: + +1. **`createRoutesStub`** - Creates a complete route structure for testing components with their actual route configuration, loaders, and nested routes. +2. **`MemoryRouter`** - Provides a minimal routing context for components that just need router hooks to work. + +Choose your approach based on what your component actually needs from the router. + +## When to Use Each Approach + +### `createRoutesStub` (Recommended) + +Use `createRoutesStub` when your component: +- Relies on route parameters (`useParams`) +- Uses loader data (`useLoaderData`) or `clientLoader` +- Has nested routes or uses `` +- Needs to test navigation between routes + +> [!NOTE] +> `createRoutesStub` is intended for unit testing **reusable components** that depend on router context. For testing full route/page components, consider E2E tests (Playwright, Cypress) instead. + +```typescript +import { createRoutesStub } from "react-router"; +import { render } from "@testing-library/react"; + +const RouterStub = createRoutesStub([ + { + Component: MyRouteComponent, + path: "/conversations/:conversationId", + }, +]); + +render(); +``` + +**With nested routes and loaders:** + +```typescript +const RouterStub = createRoutesStub([ + { + Component: SettingsScreen, + clientLoader, + path: "/settings", + children: [ + { + Component: () =>
, + path: "/settings", + }, + { + Component: () =>
, + path: "/settings/mcp", + }, + ], + }, +]); + +render(); +``` + +> [!TIP] +> When using `clientLoader` from a Route module, you may encounter type mismatches. Use `@ts-expect-error` as a workaround: + +```typescript +import { clientLoader } from "@/routes/settings"; + +const RouterStub = createRoutesStub([ + { + path: "/settings", + Component: SettingsScreen, + // @ts-expect-error: loader types won't align between test and app code + loader: clientLoader, + }, +]); +``` + +### `MemoryRouter` + +Use `MemoryRouter` when your component: +- Only needs basic routing context to render +- Uses `` components but you don't need to test navigation +- Doesn't depend on specific route parameters or loaders + +```typescript +import { MemoryRouter } from "react-router"; +import { render } from "@testing-library/react"; + +render( + + + +); +``` + +**With initial route:** + +```typescript +render( + + + +); +``` + +## Anti-patterns to Avoid + +### Using `BrowserRouter` in tests + +`BrowserRouter` interacts with the actual browser history API, which can cause issues in test environments: + +```typescript +// ❌ Avoid +render( + + + +); + +// ✅ Use MemoryRouter instead +render( + + + +); +``` + +### Mocking router hooks when `createRoutesStub` would work + +Mocking hooks like `useParams` directly can be brittle and doesn't test the actual routing behavior: + +```typescript +// ❌ Avoid when possible +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: () => ({ conversationId: "123" }), + }; +}); + +// ✅ Prefer createRoutesStub - tests real routing behavior +const RouterStub = createRoutesStub([ + { + Component: MyComponent, + path: "/conversations/:conversationId", + }, +]); + +render(); +``` + +## Common Patterns + +### Combining with `QueryClientProvider` + +Many components need both routing and TanStack Query context: + +```typescript +import { createRoutesStub } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +const RouterStub = createRoutesStub([ + { + Component: MyComponent, + path: "/", + }, +]); + +render(, { + wrapper: ({ children }) => ( + + {children} + + ), +}); +``` + +### Testing navigation behavior + +Verify that user interactions trigger the expected navigation: + +```typescript +import { createRoutesStub } from "react-router"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const RouterStub = createRoutesStub([ + { + Component: HomeScreen, + path: "/", + }, + { + Component: () =>
, + path: "/settings", + }, +]); + +render(); + +const user = userEvent.setup(); +await user.click(screen.getByRole("link", { name: /settings/i })); + +expect(screen.getByTestId("settings-screen")).toBeInTheDocument(); +``` + +## See Also + +### Codebase Examples + +- [settings.test.tsx](routes/settings.test.tsx) - `createRoutesStub` with nested routes and loaders +- [root-layout.test.tsx](routes/root-layout.test.tsx) - `createRoutesStub` with `initialEntries` navigation +- [chat-interface.test.tsx](components/chat/chat-interface.test.tsx) - `MemoryRouter` usage + +### Official Documentation + +- [React Router Testing Guide](https://reactrouter.com/start/framework/testing) - Official guide on testing with `createRoutesStub` +- [MemoryRouter API](https://reactrouter.com/api/declarative-routers/MemoryRouter) - API reference for `MemoryRouter` diff --git a/__tests__/settings-schema-descriptions.test.ts b/__tests__/settings-schema-descriptions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcbafbcfae5aec830f8455ec5d6cbbb4a4c9fab8 --- /dev/null +++ b/__tests__/settings-schema-descriptions.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; + +describe("settings schema descriptions", () => { + it("provides helper descriptions for every schema-driven settings field", () => { + const schemas = [ + MOCK_DEFAULT_USER_SETTINGS.agent_settings_schema, + MOCK_DEFAULT_USER_SETTINGS.conversation_settings_schema, + ].filter((schema): schema is NonNullable => Boolean(schema)); + + const missingDescriptions = schemas.flatMap((schema) => + schema.sections.flatMap((section) => + section.fields + .filter((field) => !field.description?.trim()) + .map((field) => field.key), + ), + ); + + expect(missingDescriptions).toEqual([]); + }); +}); diff --git a/__tests__/vite-config.test.ts b/__tests__/vite-config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..daa800abc3176a978370406a16079033a243ddf3 --- /dev/null +++ b/__tests__/vite-config.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment node +import viteConfig from "../vite.config"; +import { afterEach, describe, expect, it } from "vitest"; + +afterEach(() => { + delete process.env.BUILD_LIB; +}); + +describe("vite optimizeDeps", () => { + it("prebundles core client entry dependencies", async () => { + const config = await viteConfig({ mode: "development", command: "serve" }); + const optimizedDeps = config.optimizeDeps?.include ?? []; + + expect(optimizedDeps).toEqual( + expect.arrayContaining([ + "react", + "react/jsx-runtime", + "react-dom/client", + "react-router/dom", + ]), + ); + }); +}); + +describe("vite path resolution", () => { + it("uses Vite's native tsconfig paths support", async () => { + const config = await viteConfig({ mode: "development", command: "serve" }); + + expect(config.resolve?.tsconfigPaths).toBe(true); + expect(config.plugins).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "vite-tsconfig-paths" }), + ]), + ); + }); +}); + +describe("vite app build", () => { + it("configures Rolldown code splitting for large vendor chunks", async () => { + const config = await viteConfig({ mode: "production", command: "build" }); + const appBuild = config as { + build?: { + rolldownOptions?: { + output?: { + codeSplitting?: { + groups?: Array<{ + name?: string; + maxSize?: number; + entriesAware?: boolean; + }>; + }; + }; + }; + }; + }; + + expect(appBuild.build?.rolldownOptions?.output?.codeSplitting?.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "vendor", + maxSize: 450 * 1024, + entriesAware: true, + }), + ]), + ); + }); +}); + +describe("vite library build", () => { + it("configures a dual-format preserved-module library build", async () => { + process.env.BUILD_LIB = "true"; + + const config = await viteConfig({ mode: "production", command: "build" }); + + expect((config as { copyPublicDir?: boolean }).copyPublicDir).toBe(false); + expect(config.build?.lib).toMatchObject({ + formats: ["es"], + }); + expect(config.build?.rollupOptions?.external).toEqual( + expect.arrayContaining(["react", "react-dom", "react-router"]), + ); + expect(config.build?.rollupOptions?.output).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + format: "es", + preserveModules: true, + preserveModulesRoot: "src", + }), + expect.objectContaining({ + format: "cjs", + preserveModules: true, + preserveModulesRoot: "src", + exports: "named", + }), + ]), + ); + }); +}); diff --git a/__tests__/vitest-setup-progress-event.test.ts b/__tests__/vitest-setup-progress-event.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d7fe3c90f6f85293b96aaf2af01656223b2624e --- /dev/null +++ b/__tests__/vitest-setup-progress-event.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +/** + * Regression test for the `ReferenceError: ProgressEvent is not defined` + * unhandled rejection that intermittently failed whole CI runs + * (all tests green, exit code 1). + * + * MSW's XMLHttpRequest interceptor evaluates the bare `ProgressEvent` + * identifier inside async response callbacks. Vitest's jsdom teardown does + * `keys.forEach((key) => delete global[key])`, so any *own* property named + * `ProgressEvent` — jsdom's, or a polyfill a setup file installed — is gone + * once the environment for a test file is torn down. A callback that settles + * after that point then throws. + * + * `vitest.setup.ts` therefore keeps a fallback on `globalThis`'s prototype + * chain, which `delete` cannot reach. This test performs exactly the deletion + * teardown performs and asserts the identifier still resolves. + */ +describe("ProgressEvent fallback in vitest.setup.ts", () => { + it("resolves the bare identifier after teardown deletes the own property", () => { + const live = Object.getOwnPropertyDescriptor(globalThis, "ProgressEvent"); + expect(live).toBeDefined(); + + // What vitest's jsdom teardown does to every jsdom key. + delete (globalThis as { ProgressEvent?: unknown }).ProgressEvent; + + try { + // Before the fix this is "undefined", and the construction below throws + // ReferenceError — the exact failure seen in CI. + expect(typeof ProgressEvent).toBe("function"); + + const event = new ProgressEvent("error", { + lengthComputable: true, + loaded: 3, + total: 7, + }); + + expect(event).toBeInstanceOf(Event); + expect(event.type).toBe("error"); + expect(event.lengthComputable).toBe(true); + expect(event.loaded).toBe(3); + expect(event.total).toBe(7); + } finally { + if (live) Object.defineProperty(globalThis, "ProgressEvent", live); + } + }); + + it("prefers jsdom's own ProgressEvent while the environment is alive", () => { + // The own property shadows the prototype fallback, so nothing observes the + // stand-in until teardown removes jsdom's class. + expect( + Object.getOwnPropertyDescriptor(globalThis, "ProgressEvent"), + ).toBeDefined(); + expect(new ProgressEvent("progress").type).toBe("progress"); + }); + + it("does not add ProgressEvent to plain objects", () => { + // The fallback lives on globalThis's own prototype chain, which in Node is + // not Object.prototype — so it must not leak onto ordinary objects. + expect("ProgressEvent" in {}).toBe(false); + }); +}); diff --git a/docs/ACP_AGENTS.md b/docs/ACP_AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..5c54c16437936729d655e74c06cf181ee06fc0ee --- /dev/null +++ b/docs/ACP_AGENTS.md @@ -0,0 +1,239 @@ +# Using ACP agents + +Agent Canvas can drive your conversations with the built-in **OpenHands** agent or +with an external **ACP agent** — Claude Code, Codex, or Gemini CLI. This guide +explains what ACP agents are, how to onboard one, and how to switch agents or +models later. + +## What is an ACP agent? + +The [Agent Client Protocol (ACP)](https://agentclientprotocol.com/protocol/overview) +is a standard for talking to coding agents over JSON-RPC on stdio. Instead of +Agent Canvas calling an LLM directly, the Agent Server spawns the agent's own CLI +as a subprocess and relays each turn to it. The external agent manages its own +LLM, tools, and execution; Agent Canvas sends messages and renders what comes +back. + +```mermaid +flowchart LR + canvas["Agent Canvas
(this UI)"] + server["Agent Server"] + acp["ACP subprocess
(e.g. claude-agent-acp)"] + llm["LLM provider
(Anthropic / OpenAI / Google)"] + canvas -- "PATCH /api/settings
(agent_kind, acp_*)" --> server + canvas -- "conversation turns" --> server + server -- "spawn + JSON-RPC over stdio" --> acp + acp -- "API calls" --> llm +``` + +The Agent Server owns the subprocess and the credentials; Agent Canvas only +records *which* agent to run and surfaces a form for the secrets it needs. The +agent choice is stored per backend, so switching backends can switch agents. + +## Supported providers + +The provider list is sourced from the SDK registry +(`openhands.sdk.settings.acp_providers`, mirrored into +`@openhands/typescript-client`) and enriched with Canvas UI metadata in +[`src/constants/acp-providers.ts`](../src/constants/acp-providers.ts). Adding or +changing a provider happens upstream in the SDK, not here. + +| Provider | Default command | +|---|---| +| **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp` | +| **Codex** | `npx -y @agentclientprotocol/codex-acp` | +| **Gemini CLI** | `npx -y @google/gemini-cli --acp` | + +See [Authentication](#authentication) for how each one authenticates. + +## Authentication + +> [!IMPORTANT] +> ACP agents authenticate **two ways: a subscription login, or an API key** — and +> the onboarding fields are optional. If you're already signed in to the +> provider's CLI on the machine the agent runs on, it reuses that login +> automatically, so locally you often don't need a key at all. **The login takes +> priority over an API key:** while you're signed in, a key set in the +> environment isn't used — so the onboarding key fields do nothing and can be +> left blank. + +A "subscription login" is the credential the provider's own CLI stores when you +sign in once — a file in your home directory, or, for Claude Code on macOS, the +system **Keychain**. When the Agent Server runs **on that same machine** (a local +or self-hosted backend), the provider CLI finds that login automatically — no API +key required. On a clean cloud sandbox there's no stored login, so an API key is +needed instead. + +| Provider | Subscription login (auto-detected) | API key | +|---|---|---| +| **Claude Code** | A Claude Code login (Pro/Max), from Claude Code's own credential store: the **macOS Keychain**, or `~/.claude/.credentials.json` on Linux | `ANTHROPIC_API_KEY` *(onboarding)* | +| **Codex** | A ChatGPT login (`codex login`) cached at `~/.codex/auth.json` | `OPENAI_API_KEY` *(onboarding)* | +| **Gemini CLI** | Your Google login (`gemini`/`gemini --acp`) cached at `~/.gemini/oauth_creds.json` | `GEMINI_API_KEY` *(onboarding)* | + +All three collect an *optional* API key (+ base URL) in onboarding. As noted +above, **a subscription / OAuth login takes priority over an API key** — when the +provider's CLI is signed in, a key set in the environment is not used. Verified +per provider: + +- **Codex** — `codex login status` keeps reporting the ChatGPT login even with + `OPENAI_API_KEY` set. +- **Gemini CLI** — uses the OAuth auth type chosen at `gemini` login; + `GEMINI_API_KEY` is only consulted if you switch the auth type. The free Google + login is the common no-key path locally — sign in once and it **just works**. +- **Claude Code** — with both present, `claude auth status` reports it is + authenticated via the subscription (`claude.ai`), not the key. The login is + auto-detected from the macOS Keychain (or `~/.claude/.credentials.json` on + Linux); `CLAUDE_CONFIG_DIR` is **not** required for it — it only relocates + Claude Code's config directory (settings/history, not the token; e.g. for + containers or multiple accounts) and signals the SDK to strip a conflicting + `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`. + +The one exception is the **base URL** (`*_BASE_URL`): a custom value points the +CLI at a different endpoint (a proxy or gateway) and *does* take effect even +under a login — for Gemini it rides the ACP `gateway` param. It's an advanced +override, not needed for normal use. + +## Onboarding an ACP agent + +First-time users get a four-step onboarding modal. To onboard an ACP agent: + +1. **Choose agent** — pick Claude Code, Codex, or Gemini CLI instead of + OpenHands. The choice is saved immediately to your backend's settings. +2. **Check backend** — confirms Agent Canvas can reach the Agent Server. +3. **Set up credentials** — enter the provider's credentials. Beyond the API + key (+ optional base URL), this step also collects the credentials a + *containerized* backend needs, since a fresh container has no host login: + - **Codex** — `CODEX_AUTH_JSON` (the contents of `~/.codex/auth.json`). + - **Claude Code** — `CLAUDE_CODE_OAUTH_TOKEN` (a Pro/Max OAuth token). + - **Gemini CLI** — `GOOGLE_APPLICATION_CREDENTIALS_JSON` (Vertex SA / ADC JSON) + plus `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, and + `GOOGLE_GENAI_USE_VERTEXAI`. + + On a **local** backend the step is optional (a host login is reused + automatically); on a **Docker / cloud** backend it's **required**, because + there's no host login to fall back on. When the login probe detects an + existing session, the step shows a "you're already signed in" banner and + stays skippable. +4. **Say hello** — creates your first conversation and closes the modal. + +> [!NOTE] +> On a local backend every credential field is optional and the step is +> skippable. Leave a field blank to reuse a key already set on the backend, or to +> authenticate the agent through a subscription / OAuth login instead. + +### How credentials reach the agent + +Each credential you enter is saved as a **global secret** whose name is exactly +the environment variable the Agent Server exports into the ACP subprocess (e.g. +`ANTHROPIC_API_KEY`). Saving in onboarding is identical to adding the secret +under **Settings → Secrets**, where you can edit or remove it anytime. Keeping +the secret name equal to the env var is what makes a saved key actually reach the +provider CLI. + +## Running ACP agents in a Docker container + +The walkthrough above assumes the Agent Server runs on your own machine, where +the provider CLIs reuse a host login. You can also run the Agent Server **in a +container** — Canvas drives it the same way, but since a fresh container has no +host login, you supply credentials through the UI and Canvas sends them inline +on the conversation start request. + +A ready-to-run setup lives in +[`examples/acp-docker/`](../examples/acp-docker/) (`docker compose up`, then +point Canvas at it). In short: + +```bash +# 1. Agent Server in a container (CORS allows localhost, so the browser talks +# to it directly). The image pre-installs the ACP CLI wrappers. New +# canvas_ui_control calls use client_tools; the Python mount keeps +# pre-migration conversations loadable when persisted metadata imports +# canvas_ui_tool. +# Minimum image: 1.28.0-python (first compatible ACP provider/model protocol +# surface for current Canvas). Override SHA with a newer build. +docker run -d --name oh-acp -p 8010:8000 -v acp-data:/workspace \ + -v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \ + ghcr.io/openhands/agent-server:1.28.0-python + +# 2. Canvas pointed at the container. +VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend +``` + +### How credentials reach a containerized agent + +In onboarding's **Set up credentials** step, the credentials you enter are saved +as global secrets in the agent-server's secret store (as usual). The start +request then references each as a **`LookupSecret`** — uniformly for ACP and +non-ACP — and the agent-server resolves the value back from its own store at +spawn time. For ACP this resolution runs **off the event loop** +(software-agent-sdk#3510), so the loopback fetch does not self-deadlock. The +SDK's `acp_file_secrets` defaults then: + +- materialise `CODEX_AUTH_JSON` back to `auth.json` under `CODEX_HOME` and point + Codex at it; +- materialise `GOOGLE_APPLICATION_CREDENTIALS_JSON` to a file referenced by + `GOOGLE_APPLICATION_CREDENTIALS` and route Gemini through Vertex AI; +- export the rest (`CLAUDE_CODE_OAUTH_TOKEN`, project/location, API keys) as env + vars for the CLI. + +Canvas just sends the secrets — it does **not** hand-roll the file +materialisation. The `npx -y ` command is rewritten to the pinned +pre-installed binary inside the container by the SDK, so no command change is +needed. + +> [!IMPORTANT] +> **Do not set `ANTHROPIC_BASE_URL` alongside the Claude OAuth token.** An +> inherited LiteLLM base URL silently breaks the token's bearer auth (it routes +> the request away from Anthropic). Canvas never derives a base-URL secret from +> your LLM settings — but a base URL you save yourself rides along on every +> start request like any other saved secret, which is why the credential forms +> warn when both are set. Only set it deliberately, and not with the OAuth path. + +> [!IMPORTANT] +> **Gemini Vertex needs a fresh ADC.** Run `gcloud auth application-default login` +> before copying `~/.config/gcloud/application_default_credentials.json` — a stale +> token surfaces as `invalid_rapt`, which is a credential problem, not a Canvas +> bug. + +> [!NOTE] +> **Pick a non-flash Gemini model.** gemini-cli 0.45.x re-resolves any `*-flash` +> model id at generation time to its *current default* flash (e.g. +> `gemini-2.5-flash` silently ran `gemini-3-flash`, which 404s on projects that +> don't serve it — software-agent-sdk#3532). Only a non-flash id sticks, so +> Canvas preselects `gemini-2.5-pro`. If a Gemini turn fails with +> `Publisher Model … was not found`, check the selected model isn't a flash id. + +### Per-conversation isolation + +Concurrent same-provider conversations in one container share a HOME, so they can +race on the CLI's auth/config/lock files. The SDK supports opting into a +per-conversation data dir (`acp_isolate_data_dir`, software-agent-sdk#3492), but +the released `@openhands/typescript-client` does not yet expose it on +`ACPAgentSettings`, so Canvas can't send it without risking a validation error on +older servers. This is tracked as a follow-up (agent-canvas#1019); cloud +grouping isolation is separate (agent-canvas#1016). + +## Switching agent or model later + +Open **Settings → Agent** at any time: + +- **Agent** — switch between **OpenHands** and **ACP**. +- **Preset** — pick a built-in provider (Claude Code, Codex, Gemini CLI) or + **Custom** to point at any other ACP server. +- **Command** — the command line used to spawn the subprocess. Selecting a preset + fills this in; editing it to match another preset re-detects that provider. + API keys are *not* entered here — they live in the Secrets panel. +- **Model** — choose a suggested model for the provider or enter a custom model + override. Built-in providers save a concrete model rather than leaving it + blank. + +Saving writes an `agent_settings_diff` (`agent_kind`, `acp_server`, +`acp_command`, `acp_model`) to `PATCH /api/settings`. A running conversation +keeps the agent it started with; the new choice applies to conversations you +start afterward. + +## Custom ACP servers + +Any stdio ACP server works: choose **Custom** in Settings → Agent and enter its +launch command. Custom servers have no curated model list, so enter the model ID +the server expects (if any) as a custom model. Pass credentials by adding the +env vars the server reads as global secrets under **Settings → Secrets**. diff --git a/docs/CANVAS_EXTENSIONS_TESTING.md b/docs/CANVAS_EXTENSIONS_TESTING.md new file mode 100644 index 0000000000000000000000000000000000000000..1d3cd2f7db496b39f08ccb2ecba1a3fbb3e0b315 --- /dev/null +++ b/docs/CANVAS_EXTENSIONS_TESTING.md @@ -0,0 +1,93 @@ +# Canvas Extensions manual testing + +Canvas Extensions can be exercised locally before the Agent Server implements +the `/api/canvas-extensions` endpoints. Canvas's existing MSW development mode +contains an in-memory implementation of the API and serves the checked-in demo +extension bundle through the same frontend service and runtime used in a real +deployment. + +This path is for frontend development only. It does not test Agent Server +installation, filesystem validation, persistence, authentication, or Git +resolution. + +## Start the mock frontend + +From the repository root, run: + +```sh +VITE_FRONTEND_PORT=3102 \ +VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \ +VITE_SESSION_API_KEY=canvas-extension-dev \ +npm run dev:mock +``` + +Port `3102` avoids the `3001` Vite process used by the normal local stack. The +backend URL only gives Canvas a local backend identity; MSW intercepts the +extension requests in the browser. The mock also covers the settings and server +information probes needed to mark that backend healthy, so the Agent Server +does not need the extension endpoints and does not need to be running. + +Open . Do not use the normal ingress URL at +`http://localhost:8000` for this test because its `/api` traffic goes directly +to the unmodified Agent Server rather than through the mock browser session. + +If the browser profile already contains incompatible backend or onboarding +state, use a private window or clear local storage for `localhost:3102` and +reload. + +## Install and enable the fixture + +1. In **Customize -> Extensions**, select **Add extension**. +2. Enter this exact source: + + ```text + src/fixtures/canvas-extensions/demo-page + ``` + +3. Leave **Ref** and **Repository path** empty, then select **Install**. +4. Confirm that **Demo page** appears disabled. Installation must not execute + the bundle or add its navigation item. +5. Turn on the extension and accept the trusted-code confirmation. +6. Confirm that **Extension demo** appears in the main left rail. +7. Open it and verify the page says **Hello from a Canvas Extension**. +8. Visit `/extensions/demo-page/hello/nested` directly and verify the page + renders `Nested extension path: nested`. + +## Lifecycle checks + +- **Disable:** turn the extension off. Its rail item should disappear, and its + route should no longer render the contributed page. +- **Re-enable:** turn it on again. The item and page should return without a + Canvas restart. +- **Uninstall:** select **Uninstall** and confirm. The inventory and rail item + should become empty. +- **Reload:** reload the page and confirm the installation and enablement are + retained for this browser tab. The mock uses session storage and clears when + you uninstall it or end the browser session. + +## Test an extension edit + +Edit +`src/fixtures/canvas-extensions/demo-page/extension.js`, restart the mock +frontend if Vite does not rebuild the raw fixture import automatically, then +uninstall and reinstall the fixture. This allows page mounting, cleanup, +subrouting, and use of the host API to be developed before backend support is +available. + +The fixture must remain a self-contained browser ES module: it may not rely on +bare package imports or additional output chunks. + +## What still requires the Agent Server + +Repeat this flow against `http://localhost:8000/extensions` after the backend +contract lands. That test must additionally verify: + +- Git and backend-local-path installation; +- immutable revision resolution; +- manifest, traversal, symlink, and entrypoint validation; +- persistence across Agent Server and browser restarts; +- session-authenticated bundle delivery; +- isolation when switching between active backends. + +The backend contract and acceptance criteria are documented in +[`specs/canvas-extensions.md`](../specs/canvas-extensions.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..17961758010a0dbb52a1f011c4185d8feeca39d1 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,205 @@ +# Development + +This document is for contributors working on `agent-canvas` itself. + +## Recommended local workflow + +`npm run dev` runs the full local stack (agent-server + automation backend via +`uvx`, Vite dev server with live reload, and an ingress proxy) — all without +Docker. + +## Repository boundaries + +This repository contains the Agent Canvas frontend and local-stack orchestration. Use the sibling repositories for their owned layers: + +- [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) owns the Python SDK, Agent Server, agent/tool behavior, conversations, workspaces, events, and server API. +- [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client) owns browser-compatible typed access to that Agent Server API. Add client methods there rather than reimplementing API calls in Canvas. +- [`OpenHands/extensions`](https://github.com/OpenHands/extensions) owns reusable skills, plugins, automations, and integrations; [`OpenHands/automation`](https://github.com/OpenHands/automation) owns automation definitions, scheduling, webhooks, run history, and dispatching; Agent Server/SDK code executes the dispatched conversations. + +When a feature crosses repositories, implement the backend contract in the SDK first, expose it through `typescript-client`, and consume it in Canvas. Coordinate automation lifecycle changes in `automation`. See the repository [contributor notes](../AGENTS.md) and follow the [custom code-review guide](../.agents/skills/custom-codereview-guide.md) for every pull request. + + +For a static frontend build (better for slow networks, remote access, tunnels): + +```sh +npm run dev:static +``` + +The published `agent-canvas` binary also supports partial-stack modes when you want to run the frontend and backend processes separately: + +```sh +agent-canvas --frontend-only +agent-canvas --backend-only +``` + +Both modes still start the ingress proxy; the proxy only routes to the services started by that mode. + +The dev stack uses `uvx` to run a temporary `agent-server` +installation on `127.0.0.1:18000` and points the frontend at it. It isolates +conversation persistence by setting separate `OH_CONVERSATIONS_PATH`, +`OH_BASH_EVENTS_DIR`, and `OH_VSCODE_PORT` values under `.openhands-dev/`, and +keeps its tmux sockets under `~/.openhands/agent-canvas/tmux` (via +`TMUX_TMPDIR`), so it does not collide with other local or cloud-backed +OpenHands sessions. If `$HOME` is on a filesystem that does not support Unix +domain sockets (some devcontainers, NFS/CIFS homes), set the standard +`TMUX_TMPDIR` env var to a local path such as `/tmp` and the dev stack will use +it instead. + +### Environment Variables + +| Variable | Description | Default | +| ------------------------- | ------------------------------ | ------- | +| `PORT` | Ingress port | `8000` | +| `OH_AUTOMATION_GIT_REF` | Git ref for automation backend (overrides the pinned default version) | *(unset)* | +| `OH_AGENT_SERVER_GIT_REF` | Git ref for agent-server (overrides the pinned default version) | *(unset)* | + +### Alternative: Minimal Mode (without Automation) + +To run without the automation service: + +```sh +npm run dev:minimal +``` + +This runs only agent-server + Vite (no automation backend or ingress). +Access at `http://localhost:3001/` + +### Agent server version selection + +By default, the latest released version from PyPI is used. You can override this (highest precedence first): + +```sh +# Run against a local software-agent-sdk checkout. +OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk npm run dev + +# Use a git branch or commit (takes precedence over version) +OH_AGENT_SERVER_GIT_REF=main npm run dev +OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev + +# Use a specific PyPI version +OH_AGENT_SERVER_VERSION=1.18.0 npm run dev +``` + +`OH_AGENT_SERVER_LOCAL_PATH` must be an absolute path to a `software-agent-sdk` checkout containing the `openhands-agent-server`, `openhands-sdk`, `openhands-tools`, and `openhands-workspace` workspace packages. The agent-server itself is rebuilt from local source on each start (`uvx --reinstall`); the other workspace packages are installed editable, so their source changes take effect without a rebuild. + +### Other useful overrides + +- `OH_CANVAS_SAFE_BACKEND_PORT` — backend port for the isolated server (default `18000`) +- `OH_CANVAS_SAFE_VSCODE_PORT` — VS Code sidecar port (default `backend port + 1`) +- `OH_CANVAS_SAFE_STATE_DIR` — base directory for isolated server state +- `VITE_WORKING_DIR` — repo root used for new conversations (defaults to the current checkout) + +## Alternative development workflows + +### Multiple local backends (shared persistence) + +To run a second standalone agent-server alongside `npm run dev` while sharing +its conversation history and encrypted secrets, you can use the +`npm run dev:extra-backend` helper. It launches an extra server on `:18002` that +reuses the bundled instance's state dir. + +### Frontend against an existing backend + +Use this only if you intentionally started `agent-server` yourself or want the frontend to talk to another backend: + +```sh +npm run dev:frontend +``` + +The frontend-only workflow expects the backend at `127.0.0.1:8000` by default. + +If you set `LOCAL_BACKEND_API_KEY`, it is used as the API key for the agent-server (mapped internally to `OH_SESSION_API_KEYS_0`). The launcher auto-generates and persists a key when `LOCAL_BACKEND_API_KEY` is not set. + +### Mock mode + +If you want to run the frontend without a live backend, use: + +```sh +npm run dev:mock +``` + +## Build and test + +```sh +npm run test +npm run build +npm run start +``` + +Useful targeted verification for the isolated dev launcher: + +```sh +npm run test -- __tests__/api/agent-server-config.test.ts __tests__/scripts/dev-safe.test.ts +``` + +### Mutation testing + +Stryker checks whether the Vitest suite detects deliberate changes to the +first-party TypeScript source under `src/`. The default configuration excludes +tests, declarations, generated files, fixtures, mocks, and development seeds. + +```sh +# Full mutation run (expensive for the whole frontend) +npm run test:mutation + +# Reuse results from the previous run +npm run test:mutation:incremental + +# Mutate only production files changed from the local main branch +npm run test:mutation:diff + +# Compare with another base ref, such as the latest remote main +npm run test:mutation:diff -- origin/main +``` + +The HTML report is written to `reports/mutation.html`. Mutation scores are +report-only initially; establish a stable baseline before adding a failing +threshold. + +Stryker does not cover the small Python surface in this repository; mutating it +would need a Python test harness and Python-specific mutation tool. + +## CSS isolation and host-app customization + +The standalone app and the exported provider/root wrapper now scope all bundled CSS under a dedicated shell element with the `data-agent-server-ui` attribute. That means Tailwind utilities, HeroUI component styles, xterm styles, and local CSS only apply inside the OpenHands UI subtree instead of leaking into a host app. + +### Embedding strategy + +- Use `AgentServerUIProviders` in host apps. It renders a scoped style root by default. +- For direct wrapper control, use `AgentServerUIRoot`. +- The standalone app opts out of the provider wrapper because the router layout already renders the scoped root. + +### Customization strategy + +Theme and surface tokens are exposed as CSS custom properties on the scoped root. You can override them either through the provider/root `styleOverrides` prop or with host CSS targeting `[data-agent-server-ui]`. + +```tsx + + + +``` + +If you want Tailwind layout utilities on the inner themed container, pass `contentClassName` instead of `className`, because the outer scope element is what all generated selectors key off of. + +## Environment variables + +You can create a `.env` file in the project directory with these variables based on `.env.sample`. + +| Variable | Description | Default Value | +| --------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| `VITE_BACKEND_BASE_URL` | Full base URL for the agent server used by direct browser requests | current browser origin | +| `VITE_BACKEND_HOST` | Backend host used by the Vite dev proxy | `127.0.0.1:8000` | +| `VITE_SESSION_API_KEY` | (Internal) Session API key injected by the launcher — set `LOCAL_BACKEND_API_KEY` instead | - | +| `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` | +| `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` | +| `VITE_BASE_PATH` | Build/serve the SPA under a subpath such as `/canvas` | `/` | +| `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` | +| `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` | +| `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` | +| `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification for proxied backend requests | `false` | diff --git a/docs/DefenseClaw.md b/docs/DefenseClaw.md new file mode 100644 index 0000000000000000000000000000000000000000..370f8dfe503f772d33b4565e00f163a620b0b124 --- /dev/null +++ b/docs/DefenseClaw.md @@ -0,0 +1,303 @@ +# Integrating DefenseClaw with Agent Canvas + +[DefenseClaw](https://github.com/cisco-ai-defense/defenseclaw) is a security governance layer for agentic AI runtimes — it scans skills and MCP servers before they run, inspects LLM traffic at runtime, and produces durable audit evidence. This guide explains how to run DefenseClaw alongside the [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server) that powers Agent Canvas, without making any code-level changes to either project. + +> **Status:** DefenseClaw is purpose-built around the OpenClaw runtime and its TypeScript plugin hooks. The integration described here targets the lowest-friction overlap points — skill injection, LLM proxying, CLI scanning, and audit export — that work without modifying Agent Canvas or DefenseClaw source code. [Future work](#future-work-code-level-extensions) describes deeper hooks that would require code changes. + +--- + +## How the Two Systems Fit Together + +```mermaid +flowchart TD + UI["Agent Canvas (browser)"] + AS["OpenHands Agent Server\nlocalhost:18000"] + GP["DefenseClaw Guardrail Proxy\nlocalhost:4000"] + LLM["LLM Provider"] + GW["DefenseClaw Gateway Sidecar\nlocalhost:18970"] + CLI["DefenseClaw CLI / TUI"] + + UI -->|HTTP| AS + AS -->|LLM API calls| GP + GP -->|forwarded request| LLM + GW <-->|REST API| AS + CLI <-->|REST API| GW + + style GW fill:#fff3cd,stroke:#856404 + style CLI fill:#fff3cd,stroke:#856404 + style GP fill:#f8d7da,stroke:#842029 +``` + +**Shared concepts:** + +| Agent Canvas / Agent Server | DefenseClaw equivalent | +|---|---| +| Skills (`.agents/skills/`) | Skills (scanned by `cisco-ai-skill-scanner` + CodeGuard) | +| MCP servers | MCP servers (scanned by `cisco-ai-mcp-scanner`) | +| LLM settings (`base_url`) | Guardrail proxy upstream target | +| Workspace files (generated code) | CodeGuard scan surface | +| Agent Server hooks | Potential enforcement point (future work) | + +--- + +## Prerequisites + +| Component | Version | +|---|---| +| Agent Canvas / Agent Server | Current `main` | +| Python | 3.10+ | +| Go | 1.26.2+ (for DefenseClaw gateway) | +| DefenseClaw | Latest release | + +--- + +## Installation + +### 1. Install and initialise DefenseClaw + +```bash +# Install from the release script +curl -LsSf https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/scripts/install.sh | bash + +# Initialise config and enable the guardrail proxy +defenseclaw init --enable-guardrail +``` + +Verify the installation: + +```bash +defenseclaw doctor +``` + +Start the Go gateway sidecar (keep this running alongside the Agent Server): + +```bash +defenseclaw-gateway start +``` + +### 2. Start Agent Canvas + +Follow the standard [Agent Canvas quickstart](../README.md). The integration steps below assume the Agent Server is reachable at `http://localhost:18000`. + +--- + +## Integration Points + +### A. Load the CodeGuard Skill + +DefenseClaw ships a ready-made OpenHands skill — `skills/codeguard/SKILL.md` — that teaches the agent the CodeGuard security rules. When the skill is active, the agent writes code that avoids the patterns DefenseClaw blocks at scan time (hardcoded secrets, `os.system()`, string-interpolated SQL, weak crypto, path traversal, etc.). + +**Install the skill into a user or project skill directory:** + +```bash +# User-level (applies to all Agent Server conversations on this machine) +mkdir -p ~/.agents/skills/codeguard +curl -fsSL https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/skills/codeguard/SKILL.md \ + -o ~/.agents/skills/codeguard/SKILL.md + +# Project-level (checked in alongside your project, only affects that workspace) +mkdir -p .agents/skills/codeguard +curl -fsSL https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/skills/codeguard/SKILL.md \ + -o .agents/skills/codeguard/SKILL.md +``` + +The Agent Server loads skills from these directories automatically at conversation start. No restart of the server is required for user-level skills; project-level skills are loaded when the conversation workspace is opened. + +**What this achieves:** The agent's system prompt is augmented with the full CodeGuard rule set. Code it generates will pre-emptively avoid the patterns that the downstream `defenseclaw codeguard scan` would flag. + +--- + +### B. Route LLM Traffic Through the Guardrail Proxy + +The DefenseClaw guardrail proxy runs on `localhost:4000` and acts as an OpenAI-compatible reverse proxy. Pointing the Agent Server's LLM calls through it causes every prompt and completion to be inspected — in observe mode (log only) or action mode (block on policy violations). + +**Configure the LLM base URL in Agent Canvas:** + +Open the Agent Canvas settings panel → select your active backend → under **LLM settings**, set **Base URL** to: + +``` +http://localhost:4000 +``` + +Leave the model name and API key as-is. The proxy reads the original `Authorization` / `x-api-key` header, forwards the request to the real provider, and injects its own `X-DC-Target-URL` routing header — the agent code and Agent Server require no changes. + +**Via environment variable (server-side):** + +If you configure your Agent Server through environment variables, set the LLM base URL before starting it: + +```bash +# Example using OpenAI; set model and key as normal, only base_url changes +export OH_LLM__BASE_URL="http://localhost:4000" +npm run dev +``` + +> Consult the Agent Server [settings schema](https://github.com/OpenHands/software-agent-sdk/blob/main/openhands-agent-server/openhands/agent_server/settings_router.py) for the exact environment variable name used in your deployment. + +**Start the guardrail in observe mode (safe default) or action mode:** + +```bash +# Observe — log findings, never block (recommended while tuning) +defenseclaw setup guardrail --mode observe --restart + +# Action — block prompts and responses that match policies +defenseclaw setup guardrail --mode action --restart +``` + +**Supported providers:** + +The DefenseClaw proxy handles Anthropic (`api.anthropic.com`), OpenAI (`api.openai.com`), OpenRouter, Azure OpenAI, Gemini, Ollama, and Bedrock. Provider detection is automatic based on the target URL. + +--- + +### C. Scan Skills Before Loading + +Before installing a skill from the marketplace or an external source into the Agent Server, use the DefenseClaw CLI to vet it: + +```bash +# Scan a locally downloaded skill directory +defenseclaw skill scan path/to/skill-directory + +# Scan an installed skill by name (requires the skill to be registered in the DefenseClaw inventory) +defenseclaw skill scan my-skill-name + +# List all skills currently visible to DefenseClaw +defenseclaw skill list +``` + +The scanner applies `cisco-ai-skill-scanner` rules plus CodeGuard static analysis and emits a verdict (`PASS`, `WARN`, `BLOCK`) with per-finding details. HIGH and CRITICAL findings block skill use in action mode. + +**Workflow recommendation:** Add `defenseclaw skill scan ` as a pre-commit or CI step in repositories that ship skills for Agent Canvas. + +--- + +### D. Scan Agent-Generated Code + +After an agent conversation produces code in the workspace, run CodeGuard on the output before committing: + +```bash +# Scan an entire workspace directory +defenseclaw codeguard scan /path/to/workspace + +# Scan a single file +defenseclaw codeguard scan /path/to/workspace/src/auth.py + +# Output as JSON (useful in CI pipelines) +defenseclaw codeguard scan /path/to/workspace --json +``` + +CodeGuard checks for hardcoded secrets, dangerous command execution, SQL injection, unsafe deserialization, weak cryptography, SSRF-prone network calls, and path traversal — covering Python, JavaScript, TypeScript, Go, Java, Ruby, and PHP. + +**Zero-friction CI gate example (GitHub Actions):** + +```yaml +- name: Scan agent-generated code + run: | + defenseclaw codeguard scan ${{ github.workspace }} --json \ + | python3 -c " + import sys, json + findings = json.load(sys.stdin) + criticals = [f for f in findings if f.get('severity') in ('HIGH','CRITICAL')] + if criticals: + for f in criticals: + print(f'::error file={f[\"file\"]},line={f[\"line\"]}::{f[\"rule\"]}: {f[\"message\"]}') + sys.exit(1) + " +``` + +--- + +### E. Monitor via the DefenseClaw TUI and Audit Store + +All scan results, guardrail decisions, tool-call inspections, and policy verdicts are written to DefenseClaw's SQLite audit store. The TUI gives a live operator view: + +```bash +defenseclaw tui +``` + +The TUI panels cover: +- **Alerts** — recent HIGH/CRITICAL findings and blocked events +- **Scans** — historical scan results per skill/file +- **Tools** — tool-call verdicts from the inspection engine +- **Policy** — current block/allow lists + +**Export to external systems:** + +| Target | Setup | +|---|---| +| OTLP (Prometheus/Grafana/Honeycomb) | `defenseclaw setup observability --otlp-endpoint http://collector:4317` | +| Splunk HEC | `defenseclaw setup splunk --hec-url http://splunk:8088 --hec-token $TOKEN` | +| Slack / PagerDuty / Webex | `defenseclaw setup notifications --slack-webhook $SLACK_URL` | +| Local Splunk bundle (Docker) | `defenseclaw setup splunk --logs --accept-splunk-license` | + +--- + +## Integration Summary + +| Goal | Mechanism | Config change? | Code change? | +|---|---|---|---| +| Agent writes secure code by default | CodeGuard skill in `.agents/skills/` | Drop-in file | No | +| Inspect all LLM prompts and responses | Guardrail proxy at `localhost:4000` | Set `base_url` | No | +| Vet skills before loading | `defenseclaw skill scan` in CI/workflow | None | No | +| Scan agent-generated code | `defenseclaw codeguard scan ` | None | No | +| Audit trail and alerting | DefenseClaw TUI, OTLP, Splunk, webhooks | DefenseClaw config | No | + +--- + +## Future Work: Code-Level Extensions + +The following integrations would require changes to Agent Canvas, the Agent Server, or DefenseClaw, but would significantly deepen the security posture. + +### 1. Native `SecurityAnalyzer` hook + +The OpenHands SDK exposes a [`SecurityAnalyzer`](https://docs.openhands.dev/sdk/arch/security.md) interface. A custom implementation could call DefenseClaw's `/api/v1/inspect/tool` endpoint before every tool invocation — mirroring the inspection the OpenClaw TypeScript plugin performs. This would gate bash commands, file writes, and other tool calls through DefenseClaw's four-stage inspection pipeline (regex, Cisco AI Defense cloud rules, LLM judge, OPA policy) before they execute. + +```python +# Sketch — not yet implemented +class DefenseClawSecurityAnalyzer(SecurityAnalyzer): + async def analyze(self, action: Action) -> ActionSecurityRisk: + resp = await httpx.post( + "http://localhost:18970/api/v1/inspect/tool", + json={"tool": action.tool_name, "args": action.args}, + headers={"X-DefenseClaw-Client": "agent-server"}, + ) + if resp.json()["action"] == "block": + return ActionSecurityRisk.HIGH + return ActionSecurityRisk.LOW +``` + +### 2. Skill install pipeline integration + +The Agent Server's `skills_service.py` (`service_install_skill`) runs skill validation during install. A pre-install hook that calls `defenseclaw skill scan` and fails the install on HIGH/CRITICAL findings would enforce a mandatory scan gate — no skill reaches the agent without passing DefenseClaw's scanner. This change would live in `openhands-agent-server`. + +### 3. Hooks integration + +The Agent Server loads `.openhands/hooks.json` from the workspace. An `on_conversation_end` hook that runs `defenseclaw codeguard scan ` and writes findings to a structured report file would give per-session security evidence without manual operator intervention. + +### 4. Agent Canvas security dashboard + +A dedicated panel in the Agent Canvas UI that queries DefenseClaw's gateway REST API (`GET /alerts`, `GET /enforce/blocked`) would surface guardrail findings inline with the conversation view — correlating blocked prompts or tool calls with the agent turn that triggered them. + +### 5. Agent Server → DefenseClaw audit bridge + +The Agent Server supports outgoing webhooks (`WebhookSpec`). A webhook handler that forwards conversation events to `POST /audit/event` on the DefenseClaw gateway would allow DefenseClaw's audit store to record Agent Server conversation lifecycle events (start, tool invocation, finish) alongside its own security findings — building a single correlated audit trail. + +### 6. Skill registry alignment + +DefenseClaw's registry system (`defenseclaw registry add`) ingests external skill/MCP catalogs from ClawHub, Smithery, skills.sh, HTTP YAML, and Git sources. Aligning the Agent Server's marketplace skill catalog with the DefenseClaw registry would allow `defenseclaw skill scan all` to exhaustively vet the entire available catalog, not just individually installed skills. + +--- + +## References + +- [DefenseClaw GitHub](https://github.com/cisco-ai-defense/defenseclaw) +- [DefenseClaw Quick Start](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/QUICKSTART.md) +- [DefenseClaw API Reference](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/API.md) +- [DefenseClaw Guardrail Architecture](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/GUARDRAIL.md) +- [DefenseClaw CodeGuard Skill](https://github.com/cisco-ai-defense/defenseclaw/blob/main/skills/codeguard/SKILL.md) +- [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server) +- [OpenHands SDK Security Analyzer](https://docs.openhands.dev/sdk/arch/security.md) +- [Agent Canvas Self-Hosting](./SELF_HOSTING.md) + +--- + +_This document was created by an AI agent (OpenHands) on behalf of the user._ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fc72c9a976b6c8bf2aa4043ff9a9ceada9e410b3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Agent Canvas docs + +This directory contains the project documentation. + +- [Architecture](./architecture.md): system boundaries, runtime modes, and quality gates. +- [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI). +- [Development guide](./DEVELOPMENT.md) +- [Canvas Extensions manual testing](./CANVAS_EXTENSIONS_TESTING.md) +- [Self-hosting guide](./SELF_HOSTING.md) +- [Integrating DefenseClaw](./DefenseClaw.md): run the DefenseClaw security governance layer alongside the Agent Server. +- [Testing matrix](./TESTING_MATRIX.md): release smoke-test coverage across installers, operating systems, and agents. diff --git a/electron/loading.html b/electron/loading.html new file mode 100644 index 0000000000000000000000000000000000000000..c879a26351e2c9703c0869610d27e97410e00cbb --- /dev/null +++ b/electron/loading.html @@ -0,0 +1,359 @@ + + + + + + OpenHands Agent Canvas + + + +
+ +

OpenHands Agent Canvas

+

AI coding agent interface

+ +

Starting services

+

+ First launch downloads Python + the OpenHands agent server.
+ This can take a few minutes on a fresh machine. +

+
+ + +
+
+
+
+ Startup log + +
+
+
+ + + diff --git a/electron/main.mjs b/electron/main.mjs new file mode 100644 index 0000000000000000000000000000000000000000..3445cae1f683a61628765d7720dd51a0f8163d86 --- /dev/null +++ b/electron/main.mjs @@ -0,0 +1,785 @@ +/** + * Electron Main Process — Agent Canvas Desktop + * + * Starts the full Agent Canvas stack (agent-server + automation via uvx, + * static frontend, ingress proxy), then opens a native BrowserWindow once + * the ingress is ready. Shows a loading screen while backends start. + * + * Path layout (electron-builder uses directories.app: 'electron'): + * + * Packaged (macOS example): + * Contents/Resources/app/ ← __dirname (main.mjs lives here) + * main.mjs + * loading.html + * scripts/ ← copied from repo scripts/ + * config/ ← copied from repo config/ + * build/ ← static frontend + * Contents/Resources/bin/ ← process.resourcesPath/bin + * uv uvx ← bundled via extraResources + * + * Dev (npm run desktop → electron electron): + * electron/main.mjs ← __dirname = /electron/ + * scripts/ config/ build/ ← one level up: / + * system uvx from PATH + * + * When packaged, scripts/config/build are siblings of main.mjs so + * projectRoot === __dirname. In dev they are one level up. + * + * The dev command points electron at the electron/ DIRECTORY, not at + * main.mjs directly. Electron's default_app only reads name/productName/ + * version out of /package.json, so passing the file makes it look for + * electron/main.mjs/package.json, miss, and leave app.name at the host + * bundle's default — "Electron" in the menu bar and userData path. + */ + +import { + app, + BrowserWindow, + clipboard, + dialog, + ipcMain, + nativeImage, + nativeTheme, + shell, +} from "electron"; +import { chmodSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; + +import { isExternalBrowsableUrl, isLoopbackAppUrl } from "./lib/window-url-policy.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ── Path resolution ─────────────────────────────────────────────────────────── +// Packaged (directories.app: 'electron'): scripts/config/build are SIBLINGS of +// main.mjs inside Resources/app/, so projectRoot === __dirname. +// Dev (electron electron): those directories are one level UP in the +// repo root, so projectRoot === join(__dirname, '..'). +// Both branches key off __dirname (always /electron in dev), not +// app.getAppPath(), so the entry-point form doesn't affect them. + +const projectRoot = app.isPackaged ? __dirname : join(__dirname, ".."); +const buildDir = join(projectRoot, "build"); +const scriptsDir = join(projectRoot, "scripts"); + +// OpenHands raised-hands app icon, used as the BrowserWindow.icon option. +// Windows gets the multi-size icon.ico (16→256, small sizes as classic BMP +// entries — the Windows shell needs those); Linux uses the 1024×1024 PNG +// for its taskbar. On macOS the dock icon comes from the .app bundle's +// icon.icns, so this path is unused there. Both files live next to main.mjs +// in dev and are copied into Resources/app/build-resources/ via the +// `files:` array. Regenerate with `npm run generate-icons`. +const appIconPath = join( + __dirname, + "build-resources", + process.platform === "win32" ? "icon.ico" : "icon.png", +); + +// electron-builder's NSIS shortcuts are stamped with AppUserModelId +// ${APP_ID} (WinShell::SetLnkAUMI in installer.nsh). Declare the same id so +// running/pinned taskbar entries group with the shortcut and inherit its +// icon. Must match appId in electron-builder.config.mjs, and must be set +// before any BrowserWindow is created. +if (process.platform === "win32") { + app.setAppUserModelId("dev.openhands.agent-canvas"); +} + +// ── Bundled uv ──────────────────────────────────────────────────────────────── + +/** + * Inject the bundled uv binary into PATH so that uvx calls inside + * dev-with-automation.mjs resolve to our bundled binary. + * No-op in dev mode (falls back to system uv). + */ +function injectBundledUv() { + if (!app.isPackaged) return; + + const isWin = process.platform === "win32"; + const uvName = isWin ? "uv.exe" : "uv"; + const uvxName = isWin ? "uvx.exe" : "uvx"; + const binDir = join(process.resourcesPath, "bin"); + const uvPath = join(binDir, uvName); + + // We only probe for `uv` here — `uv` and `uvx` ship together in the + // bundle (`download-uv.mjs` writes both), so if `uv` is present we + // assume `uvx` is too. `uvxAvailable()` is called separately by + // start-up code to confirm the resolved binary actually runs. + if (!existsSync(uvPath)) { + console.warn("[desktop] Bundled uv not found at", uvPath); + return; + } + + // electron-builder copies files without preserving the +x bit on Unix. + if (!isWin) { + try { + chmodSync(uvPath, 0o755); + const uvxPath = join(binDir, uvxName); + if (existsSync(uvxPath)) chmodSync(uvxPath, 0o755); + } catch {} + } + + const sep = isWin ? ";" : ":"; + process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`; + console.log("[desktop] Injected bundled uv from", binDir); +} + +/** + * Verify uvx is reachable (either bundled or system). + * Returns true/false — callers show a dialog on false. + */ +function uvxAvailable() { + const cmd = process.platform === "win32" ? "uvx.exe" : "uvx"; + const r = spawnSync(cmd, ["--version"], { stdio: "pipe" }); + return r.status === 0; +} + +/** + * Inject the bundled Node.js distribution into PATH so subsequent spawns + * can find `node`, `npm`, and `npx`. + * + * When the app runs as a packaged .app on macOS, the system PATH is minimal + * (/usr/bin:/bin only) — Homebrew, nvm, asdf etc. installs of Node are + * invisible. Two breakages flow from that: + * + * 1. The dev-with-automation.mjs stack spawns `node scripts/ingress.mjs` + * and `node scripts/static-server.mjs`; if `node` is not found those + * processes fail silently and port 8000 never responds. + * 2. Most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.) + * use `command: "npx"`. When the agent-server tries to spawn one the + * missing `npx` makes the spawn fail with ENOENT; the SDK reports it + * as an `error_kind: "connection"` MCP test failure, surfaced in the + * install modal as "Could not reach the server". + * + * We tried bridging via Electron-as-Node (ELECTRON_RUN_AS_NODE=1) wrappers + * first. That fixed the ENOENT but introduced a new failure: stdio MCP + * servers spawned through the wrapper exited with "McpError: Connection + * closed" before the JSON-RPC handshake completed. Electron-as-Node is + * fine for our networking helper scripts but its stdin/stdout semantics + * differ enough from a vanilla `node` binary that stdio JSON-RPC servers + * are not reliable under it. The robust fix is to ship a real Node.js + * runtime as an extraResource (see scripts/download-node.mjs and the + * `resources/node/` entry in electron-builder.config.mjs) and just put + * its bin dir on PATH. + * + * No-op in dev mode (`npm run desktop`): the user's terminal PATH already + * has Node tooling and `app.isPackaged` is false. If the bundled dir is + * somehow missing (e.g. the download step was skipped during packaging), + * we log a loud warning and leave PATH untouched so the failure mode is + * obvious in the console rather than confusing downstream. + */ +function injectBundledNode() { + if (!app.isPackaged) return; + + const isWin = process.platform === "win32"; + const nodeRoot = join(process.resourcesPath, "node"); + // POSIX Node distributions put binaries in bin/; Windows zips put node.exe + // and the npm.cmd / npx.cmd wrappers at the distribution root. + const binDir = isWin ? nodeRoot : join(nodeRoot, "bin"); + const nodeExe = isWin ? join(nodeRoot, "node.exe") : join(binDir, "node"); + + if (!existsSync(nodeExe)) { + console.warn( + `[desktop] Bundled Node.js not found at ${nodeExe} — backend ` + + "scripts and stdio MCP servers will fail. Run `npm run download-node` " + + "and rebuild.", + ); + return; + } + + // node.exe alone is not enough. npm / npx are wrapper scripts that exec + // npm's JS entry points out of the distribution's own node_modules, and + // that directory is the one piece electron-builder drops on Windows (see + // restoreBundledNodeNpm in electron-builder.config.mjs). Since we PREPEND + // this dir to PATH, a half-copied bundle doesn't just fail to help — it + // shadows the user's working npm with shims that die on MODULE_NOT_FOUND. + // Warn loudly, but still inject: `node` itself works and the backend + // launcher scripts need it. + const npmCli = isWin + ? join(nodeRoot, "node_modules", "npm", "bin", "npm-cli.js") + : join(nodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js"); + if (!existsSync(npmCli)) { + console.warn( + `[desktop] Bundled npm is incomplete — ${npmCli} is missing. ` + + "`npx`-launched subprocesses (stdio MCP servers, ACP servers) will " + + "fail with MODULE_NOT_FOUND, and this bundle shadows any npm already " + + "on PATH. Rebuild with `npm run download-node`.", + ); + } + + // electron-builder doesn't always preserve the +x bit on POSIX. node, npm, + // and npx need to be executable for shell PATH lookup to consider them. + if (!isWin) { + const required = ["node", "npm", "npx"]; + for (const name of required) { + const p = join(binDir, name); + try { + if (existsSync(p)) chmodSync(p, 0o755); + } catch { + // best-effort: a stale read-only mount or test fixture is fine to skip + } + } + } + + const sep = isWin ? ";" : ":"; + process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`; + console.log("[desktop] Injected bundled Node from", binDir); +} + +// ── Readiness polling ───────────────────────────────────────────────────────── + +/** + * Wait until `url` responds at all (status < 500). Used to confirm the + * ingress proxy is bound — not a guarantee that the agent-server behind it + * is ready. Use {@link waitForAgentServer} for that. + */ +async function waitForUrl(url, timeoutMs = 120_000, intervalMs = 600) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (res.status < 500) return; + } catch {} + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error( + `Timed out waiting for ${url} to become ready (${timeoutMs / 1000}s).`, + ); +} + +/** + * Wait until `url` returns HTTP 200 — meaning the agent-server itself is + * serving requests, not just that the ingress proxy is up. + * + * On first launch, `uvx` has to download a Python toolchain and install + * `openhands-agent-server` and its workspace deps from PyPI, which can + * easily take a few minutes on a slow network. We poll the route end-to-end + * (through ingress on port 8000, so a missing or restarted ingress is also + * caught) instead of just probing the static-server fallback that + * `waitForUrl` would accept. + */ +async function waitForAgentServer( + url = "http://localhost:8000/server_info", + timeoutMs = 10 * 60_000, + intervalMs = 1_000, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + // Only 200 is success here. 502 from ingress means the upstream agent + // server isn't bound yet; 401 means auth is required and the bundled + // key didn't reach us — we still treat that as "the agent server is + // up", because the proxy got a real HTTP response from it. + if (res.status === 200 || res.status === 401) return; + } catch { + // Transient network / DNS / timeout — keep polling until the deadline. + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error( + `Agent server at ${url} never came up (${Math.round(timeoutMs / 1000)}s). ` + + "Check the terminal log for errors from uvx / the agent-server process.", + ); +} + +// ── Windows ─────────────────────────────────────────────────────────────────── + +let loadingWin = null; +let mainWin = null; + +// Collapsed splash size — loading.html's .container height must match. The +// expanded height reveals the startup-log console below it ("Show details"). +const LOADING_WIN_WIDTH = 460; +const LOADING_WIN_HEIGHT = 360; +const LOADING_WIN_EXPANDED_HEIGHT = 560; + +/** + * Grow or shrink the loading window to reveal/hide the startup-log console. + * Keeps the top edge fixed so the splash content doesn't jump. Invoked from + * the renderer ("Show details" toggle) and from showStartupFailure(). + */ +function setLoadingWindowExpanded(expanded) { + if (!loadingWin || loadingWin.isDestroyed()) return; + const bounds = loadingWin.getBounds(); + const height = expanded ? LOADING_WIN_EXPANDED_HEIGHT : LOADING_WIN_HEIGHT; + if (bounds.height === height) return; + // macOS ignores programmatic resizes of resizable:false windows on some + // Electron versions — lift the flag around the change. + loadingWin.setResizable(true); + loadingWin.setBounds({ ...bounds, height }, true); + loadingWin.setResizable(false); +} + +function createLoadingWindow() { + loadingWin = new BrowserWindow({ + width: LOADING_WIN_WIDTH, + // Tall enough to fit the streaming status line + the "first launch can + // take a few minutes" hint without scrollbars. + height: LOADING_WIN_HEIGHT, + resizable: false, + frame: false, + center: true, + show: false, + // Pre-paint window color; must match --oh-background in loading.html. + backgroundColor: "#0b0e14", + icon: appIconPath, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + // Bridges the startup-log console over IPC (see preload.cjs). + preload: join(__dirname, "preload.cjs"), + }, + }); + + // The renderer can only receive IPC once the page has loaded — replay the + // lines buffered until now, then stream live batches (see appendBootLog). + loadingWin.webContents.on("did-finish-load", () => { + if (!loadingWin || loadingWin.isDestroyed()) return; + clearTimeout(bootLogFlushTimer); + bootLogFlushTimer = null; + bootLogPending = []; + if (bootLog.length) { + loadingWin.webContents.send("boot-log:batch", bootLog.slice()); + } + bootLogReady = true; + if (fatalSummary) { + loadingWin.webContents.send("boot-log:fatal", fatalSummary); + } + }); + + loadingWin.loadFile(join(__dirname, "loading.html")); + loadingWin.once("ready-to-show", () => loadingWin?.show()); +} + +function createMainWindow() { + mainWin = new BrowserWindow({ + width: 1440, + height: 900, + minWidth: 800, + minHeight: 600, + show: false, + // App-shell background (--oh-background in src/index.css) — avoids white + // flashes during the show → maximize repaint after the splash closes. + backgroundColor: "#0b0e14", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + icon: appIconPath, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + mainWin.loadURL("http://localhost:8000"); + + mainWin.once("ready-to-show", () => { + loadingWin?.destroy(); + loadingWin = null; + mainWin?.show(); + mainWin?.maximize(); + }); + + // Route window.open() calls appropriately. + mainWin.webContents.setWindowOpenHandler(({ url }) => { + // The "Login with OpenHands Cloud" device-flow opens about:blank immediately + // on the user's click (to beat popup blockers), then navigates the popup to + // the OAuth verification URL once it has one. We must allow about:blank + // through so window.open() returns a non-null WindowProxy; the did-create-window + // handler below redirects the popup to the system browser when it navigates. + if (url === "about:blank") { + return { + action: "allow", + overrideBrowserWindowOptions: { width: 800, height: 700 }, + }; + } + // All other URLs open directly in the system browser. The loopback test + // goes through URL parsing: prefix matching would also accept + // attacker-controlled hosts like http://localhost.evil.com (or + // http://localhost@evil.com) and render them in a chromeless native + // window. Schemes outside the openExternal allowlist are denied + // outright — shell.openExternal would forward them to OS protocol + // handlers. + if (isLoopbackAppUrl(url)) { + return { action: "allow" }; + } + if (isExternalBrowsableUrl(url)) { + shell.openExternal(url); + } + return { action: "deny" }; + }); + + // When the renderer opens a popup (the about:blank above), watch for its + // first navigation away from about:blank. That navigation will be to the + // OAuth verification URL — open it in the system browser and close the + // now-unneeded Electron popup. + mainWin.webContents.on("did-create-window", (popupWin) => { + popupWin.webContents.on("will-navigate", (_event, url) => { + if (url !== "about:blank" && !isLoopbackAppUrl(url)) { + _event.preventDefault(); + if (isExternalBrowsableUrl(url)) { + shell.openExternal(url); + } + popupWin.close(); + } + }); + }); + + mainWin.on("closed", () => { + mainWin = null; + }); +} + +// ── Startup log buffer ──────────────────────────────────────────────────────── +// +// Every service log line (all services, all levels, sanitized) is kept in a +// bounded buffer and streamed to the loading window's console in batches over +// IPC (see preload.cjs + loading.html). The buffer is the single source of +// truth: it is replayed once the page loads (lines emitted earlier would +// otherwise be lost) and it backs the "Copy logs" action. In a packaged app +// this console is the only log surface — stdout/stderr go to /dev/null when +// launched from Finder, and the winston file logger is a no-op there (see +// AGENTS.md on the node_modules strip). + +const BOOT_LOG_MAX_LINES = 2000; +const BOOT_LOG_FLUSH_MS = 200; + +const bootLog = []; // {name, line, level}[] — level: stdout|stderr|info|warn|error +let bootLogPending = []; +let bootLogFlushTimer = null; +let bootLogReady = false; // true once loading.html has loaded and can receive +let fatalSummary = null; + +// SGR color codes AND cursor-control CSI sequences (uv/uvicorn can emit +// either when they mis-detect a TTY). +const ANSI_CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; + +/** + * Strip ANSI escapes and reduce carriage-return progress redraws (e.g. uv + * download bars arrive as one chunk of "\r"-separated frames) to the final + * frame — what a real terminal would have settled on. + */ +function sanitizeLogLine(line) { + const frames = String(line ?? "") + .replace(ANSI_CSI_RE, "") + .split("\r") + .map((s) => s.trim()) + .filter(Boolean); + return frames.length ? frames[frames.length - 1] : ""; +} + +function appendBootLog(name, line, level) { + const entry = { name, line, level }; + bootLog.push(entry); + if (bootLog.length > BOOT_LOG_MAX_LINES) { + bootLog.splice(0, bootLog.length - BOOT_LOG_MAX_LINES); + } + bootLogPending.push(entry); + if (!bootLogFlushTimer) { + bootLogFlushTimer = setTimeout(flushBootLog, BOOT_LOG_FLUSH_MS); + } +} + +function flushBootLog() { + clearTimeout(bootLogFlushTimer); + bootLogFlushTimer = null; + if (!bootLogPending.length) return; + const batch = bootLogPending; + bootLogPending = []; + // Not ready / window gone: drop the batch — the entries stay in bootLog, + // which did-finish-load replays wholesale. + if (bootLogReady && loadingWin && !loadingWin.isDestroyed()) { + loadingWin.webContents.send("boot-log:batch", batch); + } +} + +/** + * Switch the splash into its failure state: expand the console and show the + * error summary with Copy logs / Quit actions, keeping the window open so the + * user can actually read why startup failed. Returns false when the loading + * window is gone (caller falls back to a native dialog). + */ +function showStartupFailure(summary) { + if (!loadingWin || loadingWin.isDestroyed()) return false; + fatalSummary = summary; + setLoadingWindowExpanded(true); + if (bootLogReady) { + flushBootLog(); + loadingWin.webContents.send("boot-log:fatal", summary); + } + // If the page hasn't loaded yet, did-finish-load replays the buffer and + // then delivers fatalSummary. + return true; +} + +// IPC surface for the loading window (see preload.cjs). Guarded to that +// window's webContents so the main app window can never reach these. +function isLoadingWinEvent(event) { + return ( + loadingWin !== null && + !loadingWin.isDestroyed() && + event.sender === loadingWin.webContents + ); +} + +ipcMain.handle("boot-log:set-expanded", (event, expanded) => { + if (!isLoadingWinEvent(event)) return; + setLoadingWindowExpanded(Boolean(expanded)); +}); + +ipcMain.handle("boot-log:copy", (event) => { + if (!isLoadingWinEvent(event)) return 0; + clipboard.writeText(bootLog.map((e) => `[${e.name}] ${e.line}`).join("\n")); + return bootLog.length; +}); + +// The frameless splash has no close control; the failure state shows a Quit +// button instead. +ipcMain.handle("boot-log:quit", (event) => { + if (!isLoadingWinEvent(event)) return; + app.quit(); +}); + +// ── Backend stack ───────────────────────────────────────────────────────────── + +/** + * Update the status line on the loading window, if it's still alive. + * + * The loading screen exposes a global `window.__setLoadingStatus(line)` + * function (see loading.html) that swaps the status text. We call it via + * `executeJavaScript` so no preload script / IPC plumbing is needed. + * + * Best-effort: any failure (window destroyed, JS not loaded yet, etc.) is + * swallowed — this is purely a UX nicety and must never crash the launcher. + */ +function setLoadingStatus(line) { + if (!loadingWin || loadingWin.isDestroyed()) return; + // Limit to a single line, max ~120 chars, to keep the splash readable. + const oneLine = String(line ?? "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + if (!oneLine) return; + const safe = JSON.stringify(oneLine); + loadingWin.webContents + .executeJavaScript( + `window.__setLoadingStatus && window.__setLoadingStatus(${safe});`, + true, + ) + .catch(() => {}); +} + +/** + * Phase marker: headline + a line in the startup-log console, so the log + * records which stage a failed boot died in. + */ +function setBootPhase(message) { + appendBootLog("desktop", message, "info"); + setLoadingStatus(message); +} + +/** + * Last few `level: "error"` service log lines (spawn failures, non-zero + * exits). Appended to the startup-failure dialog: a packaged app launched + * from Finder has stdout/stderr wired to /dev/null, so without this a + * crashed ingress/static-server surfaces only as an opaque "timed out + * waiting for http://localhost:8000" message. + */ +const recentServiceErrors = []; + +/** + * Forward dev-stack service log lines to (a) the loading screen and (b) the + * terminal log. The terminal already receives them via `logService`; we add + * a tee here so the user can see what's happening on first launch when uvx + * is downloading Python + agent-server. + */ +function handleServiceLog(name, line, level) { + if (!line) return; + const clean = sanitizeLogLine(line); + if (!clean) return; + // Full-fidelity stream: every service and level goes to the console buffer. + // The one-line headline below stays filtered to the interesting services. + appendBootLog(name, clean, level); + if (name === "agent-server" || name === "automation") { + setLoadingStatus(`${name}: ${clean}`); + } + // Mirror errors to a `[desktop]` terminal line so dev runs stay grep-friendly. + if (level === "error") { + console.error(`[desktop] [${name}] ${clean}`); + // Errors from ANY service (including ingress/static, which the headline + // filter above skips) are worth showing — a dead ingress is exactly the + // case where the user would otherwise stare at a silent 120 s timeout. + setLoadingStatus(`${name}: ${clean}`); + recentServiceErrors.push(`${name}: ${clean}`); + if (recentServiceErrors.length > 5) recentServiceErrors.shift(); + } +} + +async function startStack() { + const entryUrl = pathToFileURL( + join(scriptsDir, "dev-with-automation.mjs"), + ).href; + const { main } = await import(entryUrl); + + // main() starts agent-server + automation backend + static server + ingress. + // skipNpmCheck: npm is not needed at runtime in static mode. + // agentServerReadyTimeoutMs: dev defaults to 60 s (warm uvx cache); a + // packaged binary on a fresh machine can spend several minutes inside + // uvx the first time, downloading Python + installing openhands- + // agent-server from PyPI. 10 minutes is generous but bounded. + // onServiceLog: stream uvx/agent-server output to the loading window so + // the user sees progress instead of an indefinite spinner. + const result = await main({ + bannerTitle: "OpenHands Agent Canvas", + staticMode: true, + staticDir: buildDir, + mode: "agent-canvas", + isPublic: false, + skipNpmCheck: true, + agentServerReadyTimeoutMs: 10 * 60_000, + onServiceLog: handleServiceLog, + }); + + // main() returns { config, agentServerReady } — treat a timeout as a fatal + // startup error so the splash shows a clear dialog instead of dropping the + // user into a half-booted UI that will only emit "Request timeout" popups. + if (result?.agentServerReady === false) { + throw new Error( + "The agent server did not finish starting in time. " + + "On first launch this can take several minutes while uvx downloads " + + "Python and the OpenHands agent-server from PyPI. " + + "Check your internet connection and try again.", + ); + } +} + +// ── App lifecycle ───────────────────────────────────────────────────────────── + +app.whenReady().then(async () => { + nativeTheme.themeSource = "dark"; + + // Set the dock icon explicitly on macOS so `npm run desktop` shows the + // OpenHands logo instead of the default Electron logo. In a packaged + // build the .app bundle's icon.icns already provides this, but + // app.dock.setIcon() is a cheap idempotent override that also fixes + // the dev workflow. + if (process.platform === "darwin" && app.dock && existsSync(appIconPath)) { + app.dock.setIcon(nativeImage.createFromPath(appIconPath)); + } + + injectBundledUv(); + injectBundledNode(); + + if (!uvxAvailable()) { + dialog.showErrorBox( + "Missing prerequisite: uv", + app.isPackaged + ? "The bundled uv binary could not be found. Please reinstall OpenHands Agent Canvas." + : "uv (uvx) is not installed.\n\nInstall it from https://docs.astral.sh/uv/ then restart.", + ); + app.quit(); + return; + } + + createLoadingWindow(); + + try { + setBootPhase("Starting backend services…"); + await startStack(); + + // Stage 1: ingress proxy is bound (anything < 500 on /). + setBootPhase("Waiting for proxy…"); + await waitForUrl("http://localhost:8000"); + + // Stage 2: the agent-server behind the proxy is actually serving + // requests. `startStack()` already waited for this internally, but we + // re-probe end-to-end here so that if the user closes the splash race + // window between processes binding, we still open the main window with + // a live backend. Cheap (a single 200 response) when everything is up. + setBootPhase("Connecting to agent server…"); + await waitForAgentServer("http://localhost:8000/server_info", 60_000); + + setBootPhase("Ready."); + createMainWindow(); + } catch (err) { + const summary = + err.message + + " Ensure ports 8000, 18000, and 18001 are free, then try again."; + // Record the failure in the terminal and the startup-log buffer so it + // shows (and copies) as the final console line. + console.error("[desktop] Startup failed:", err); + appendBootLog("desktop", summary, "error"); + // Keep the splash open in its failure state so the full startup log can + // be read and copied; the app quits via the splash's Quit button (or + // Cmd+Q / closing the window). + if (showStartupFailure(summary)) return; + // Loading window already gone — fall back to the old dialog-and-quit. + const errorTail = recentServiceErrors.length + ? `\n\nRecent service errors:\n${recentServiceErrors.join("\n")}` + : ""; + dialog.showErrorBox("OpenHands Agent Canvas failed to start", summary + errorTail); + app.quit(); + } +}); + +// ── Graceful shutdown ───────────────────────────────────────────────────────── +// +// dev-with-automation.mjs spawns the backend processes with detached:true so +// they form their own OS process groups and survive the parent's death by +// default. We must explicitly kill them when the app quits. +// +// createShutdownHookRegistry (dev-process-utils.mjs) already registered a +// SIGTERM handler that iterates every tracked process, calls signalProcessTree +// on its group, waits for exit, then calls process.exit(0). We just need to +// fire that handler before Electron lets the process die. +// +// Flow: +// user closes window / Cmd+Q +// → window-all-closed → app.quit() +// → before-quit fires (first time) → we preventDefault + send SIGTERM +// → SIGTERM handler kills all children, calls process.exit(0) +// → before-quit fires again (cleanupStarted=true) → we return, Electron exits +// +// Windows has no real POSIX signals: process.kill(pid, "SIGTERM") would +// terminate this process WITHOUT running the "SIGTERM" listener, skipping +// cleanup and orphaning the children on ports 8000/18000/18001 (the next +// launch then fails at startup). process.emit("SIGTERM") runs the same +// registered handler in-process instead. + +let cleanupStarted = false; + +app.on("before-quit", (event) => { + if (cleanupStarted) return; // SIGTERM cleanup already running — allow exit + + cleanupStarted = true; + event.preventDefault(); + + console.log("[desktop] Stopping backend services…"); + if (process.platform === "win32") { + // Run the cleanup handler in-process (see header note). emit() returns + // false when no listener is registered — the stack never started, so + // there is nothing to clean up and we can exit immediately. + if (!process.emit("SIGTERM")) app.exit(0); + } else { + process.kill(process.pid, "SIGTERM"); + } + + // Safety net: if the SIGTERM handler doesn't finish within 6 s, force-quit. + const t = setTimeout(() => { + console.warn("[desktop] Cleanup timed out — forcing exit"); + app.exit(0); + }, 6000); + if (t.unref) t.unref(); +}); + +app.on("window-all-closed", () => { + app.quit(); +}); + +// macOS: clicking the dock icon when no window is open re-launches the app. +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + // The backend is already running — just open a new renderer window. + if (mainWin === null) createMainWindow(); + } +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000000000000000000000000000000000000..ba31decc3b7660c02e9c873ec7a1c20392e67ad1 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,7 @@ +{ + "name": "agent-canvas", + "productName": "OpenHands Agent Canvas", + "version": "1.0.0", + "private": true, + "main": "main.mjs" +} diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000000000000000000000000000000000000..3c8a1fa879e8bebc0bcdc1f4bddb3dfe99f0dc98 --- /dev/null +++ b/electron/preload.cjs @@ -0,0 +1,31 @@ +/** + * Preload for the loading window (loading.html). + * + * Bridges the startup-log console to the main process over IPC while keeping + * contextIsolation (and the default renderer sandbox) intact. The one-line + * status headline intentionally does NOT go through here — main.mjs sets it + * via executeJavaScript → window.__setLoadingStatus (see setLoadingStatus). + * + * CommonJS on purpose: sandboxed preload scripts cannot use ESM. + */ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("desktopBoot", { + /** Subscribe to batched startup-log lines: cb([{name, line, level}, …]). */ + onLogBatch(cb) { + if (typeof cb !== "function") return; + ipcRenderer.on("boot-log:batch", (_event, batch) => cb(batch)); + }, + /** Subscribe to the fatal startup-failure notification: cb(summary). */ + onFatal(cb) { + if (typeof cb !== "function") return; + ipcRenderer.on("boot-log:fatal", (_event, summary) => cb(summary)); + }, + /** Grow/shrink the window to reveal or hide the console panel. */ + setDetailsExpanded: (expanded) => + ipcRenderer.invoke("boot-log:set-expanded", Boolean(expanded)), + /** Copy the full buffered startup log to the clipboard. */ + copyLogs: () => ipcRenderer.invoke("boot-log:copy"), + /** Quit the app (failure-state action; the frameless splash has no close UI). */ + quit: () => ipcRenderer.invoke("boot-log:quit"), +}); diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..31d5801adb225f16de2d46faeb9f1915796473b3 Binary files /dev/null and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..57e1544c5fb75622676059acd8765599b2ee21db Binary files /dev/null and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..31d5801adb225f16de2d46faeb9f1915796473b3 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/browserconfig.xml b/public/browserconfig.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3930d0f047184047cb81d620436d91653438b8b --- /dev/null +++ b/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..d94186c1cc098f0ae0f7565d65b72c3fbf9b8d9b Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..a7d3fe1281fd6e00e273cae782da5eeaad180071 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f560354d7bff596fa93ae0d1d9f0b8988080fee4 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..4877e59fda7fb61e334a528414dbec8bc675985a --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000000000000000000000000000000000000..0c970efc963c4096cae4fba9e70b7c881ad03ddd --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/public/mstile-150x150.png b/public/mstile-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..bdf3ed471b3abd51648295cda04c82ddd3187d06 Binary files /dev/null and b/public/mstile-150x150.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..e9e57dc4d41b9b46e05112e9f45b7ea6ac0ba15e --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg new file mode 100644 index 0000000000000000000000000000000000000000..daa0090f0fd5dc794c01a835f54036881a54af83 --- /dev/null +++ b/public/safari-pinned-tab.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000000000000000000000000000000000000..b20abb7cbb2903c45280ba3540710669aeb63163 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/scripts/brand-dev-electron.mjs b/scripts/brand-dev-electron.mjs new file mode 100644 index 0000000000000000000000000000000000000000..748166f8b4cb3f7aa0d8bd83af4df6c383bdb19f --- /dev/null +++ b/scripts/brand-dev-electron.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +/** + * Brand the dev Electron app bundle with the product name (macOS only). + * + * Run automatically as the `predesktop` npm hook. + * + * WHY THIS EXISTS + * + * `npm run desktop` runs the app inside Electron's own prebuilt bundle, + * node_modules/electron/dist/Electron.app. macOS derives the name it shows + * in the Dock tooltip, the ⌘-Tab switcher and Finder from that bundle, at + * launch, before any JavaScript runs. No runtime API can change it — + * `app.setName()`, `app.name` and package.json `productName` only drive + * Electron's own notion of the name (menu bar, About panel, and + * app.getPath("userData")). + * + * The packaged app has never had this problem: electron-builder emits a + * bundle literally named ".app" with matching plist keys. This + * script puts the dev bundle in that same state. + * + * THE BUNDLE FILENAME IS THE PART THAT ACTUALLY SHOWS + * + * Patching the plist alone is NOT enough — verified the hard way. macOS + * prefers the bundle's filesystem name over CFBundleName/CFBundleDisplayName + * for the Dock tooltip. Two installed apps prove each half of this: + * + * DBeaver.app CFBundleName "DBeaver Community" → displays "DBeaver" + * (the filename wins over the plist) + * Antigravity.app CFBundleExecutable "Electron" → displays "Antigravity" + * (an Electron app whose executable name is irrelevant) + * + * So the fix aligns every source of the name at once: the .app directory + * name, CFBundleName and CFBundleDisplayName. That is exactly the shape of a + * packaged build, which is known to display correctly. + * + * Renaming the bundle means node_modules/electron/path.txt has to move with + * it: getElectronPath() in node_modules/electron/index.js joins path.txt onto + * dist/ and silently re-downloads Electron (~100 MB) if the result does not + * exist. The two are updated together, and the rename is rolled back if + * path.txt cannot be written. + * + * WHY THIS IS SAFE + * + * - Electron's prebuilt dist is ad-hoc *linker-signed*: `codesign -dv` + * reports `flags=0x20002(adhoc,linker-signed)`, `Info.plist=not bound`, + * `Sealed Resources=none`. The signature covers only the Mach-O, so + * editing Info.plist does not invalidate it and no re-signing is needed. + * - `npm run build:desktop` is unaffected: electron-builder packages from + * its own download cache (~/Library/Caches/electron/electron-v*.zip), + * never from node_modules/electron/dist. + * + * WHAT IT DELIBERATELY DOES NOT TOUCH + * + * CFBundleExecutable — left as "Electron". Antigravity above shows it has + * no bearing on the displayed name; it only feeds ps / Activity Monitor. + * Leaving it alone keeps path.txt's trailing segments valid. + * CFBundleIdentifier — kept at com.github.Electron. Changing it would split + * LaunchServices / TCC state per checkout for no visible gain. + * + * The changes live in node_modules, which `npm ci` wipes. That is fine: the + * `predesktop` hook re-applies them on every `npm run desktop`. + * + * This script must never block the desktop run — every failure path warns + * and exits 0. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +// Both keys are set. CFBundleDisplayName is the one LaunchServices reports; +// CFBundleName is the fallback and shows up in other bundle-name surfaces. +const NAME_KEYS = ["CFBundleDisplayName", "CFBundleName"]; + +function warn(message) { + console.warn(`[brand-dev-electron] ${message}`); +} + +/** Root of the installed `electron` package, or null if it isn't resolvable. */ +function resolveElectronPackage() { + const require = createRequire(import.meta.url); + return dirname(require.resolve("electron/package.json")); +} + +/** Current value of `key`, or null when the key is absent. */ +function readPlistString(plistPath, key) { + try { + return execFileSync( + "plutil", + ["-extract", key, "raw", "-o", "-", plistPath], + { encoding: "utf8" }, + ).trim(); + } catch { + return null; + } +} + +function writePlistString(plistPath, key, value) { + execFileSync("plutil", ["-replace", key, "-string", value, plistPath], { + stdio: "pipe", + }); +} + +/** + * Rename dist/.app to dist/.app and repoint path.txt. + * Returns { appDir, renamed }, or null if the bundle can't be determined. + */ +function ensureBundleName(pkgDir, productName) { + const distDir = join(pkgDir, "dist"); + const pathFile = join(pkgDir, "path.txt"); + if (!existsSync(pathFile)) { + warn(`No ${pathFile} — leaving the dev bundle alone.`); + return null; + } + + // e.g. "Electron.app/Contents/MacOS/Electron" — only the first segment + // (the bundle directory) is ours to rename. + const relative = readFileSync(pathFile, "utf8").trim(); + const segments = relative.split("/"); + const currentName = segments[0]; + const desiredName = `${productName}.app`; + if (!currentName.endsWith(".app")) { + warn(`Unexpected path.txt entry "${relative}" — leaving the bundle alone.`); + return null; + } + + const desiredDir = join(distDir, desiredName); + if (currentName === desiredName && existsSync(desiredDir)) { + return { appDir: desiredDir, renamed: false }; + } + + const currentDir = join(distDir, currentName); + let renamed = false; + if (existsSync(currentDir) && currentDir !== desiredDir) { + if (existsSync(desiredDir)) { + // Both present: a previous run renamed the bundle and something + // restored the original. Prefer the correctly named one and just fix + // path.txt rather than clobbering either bundle. + warn(`Both ${currentName} and ${desiredName} exist — using the latter.`); + } else { + renameSync(currentDir, desiredDir); + renamed = true; + } + } else if (!existsSync(desiredDir)) { + warn(`No Electron bundle under ${distDir} — leaving the dev bundle alone.`); + return null; + } + + // path.txt MUST agree with the directory on disk; a stale entry makes + // getElectronPath() re-download Electron on the next run. + segments[0] = desiredName; + try { + writeFileSync(pathFile, segments.join("/")); + } catch (err) { + // Undo the rename so the checkout is left in a working state. + if (existsSync(desiredDir) && !existsSync(currentDir)) { + try { + renameSync(desiredDir, currentDir); + } catch { + warn(`Could not roll back the bundle rename in ${distDir}.`); + } + } + warn(`Could not update ${pathFile}: ${err.message}`); + return null; + } + + return { appDir: desiredDir, renamed }; +} + +function main() { + // The Dock/⌘-Tab name is a macOS bundle concept. On Windows the dev + // taskbar name comes from electron.exe's version resource and on Linux + // from the .desktop file / WM_CLASS — neither exists until the app is + // packaged, so there is nothing to patch. + if (process.platform !== "darwin") return; + + const manifestPath = join(projectRoot, "electron", "package.json"); + let productName; + try { + productName = JSON.parse(readFileSync(manifestPath, "utf8")).productName; + } catch (err) { + warn(`Could not read ${manifestPath}: ${err.message}`); + return; + } + if (!productName) { + warn(`No productName in ${manifestPath} — leaving the dev bundle alone.`); + return; + } + // The name becomes a directory entry; a "/" would silently retarget it. + if (productName.includes("/")) { + warn(`productName "${productName}" cannot be used as a bundle name.`); + return; + } + + let pkgDir; + try { + pkgDir = resolveElectronPackage(); + } catch (err) { + warn(`Could not resolve the electron package: ${err.message}`); + return; + } + + let bundle; + try { + bundle = ensureBundleName(pkgDir, productName); + } catch (err) { + warn(`Could not rename the dev bundle: ${err.message}`); + return; + } + if (!bundle) return; + + const plistPath = join(bundle.appDir, "Contents", "Info.plist"); + if (!existsSync(plistPath)) { + warn(`No Info.plist at ${plistPath} — leaving the plist alone.`); + return; + } + + const stale = NAME_KEYS.filter( + (key) => readPlistString(plistPath, key) !== productName, + ); + + try { + for (const key of stale) writePlistString(plistPath, key, productName); + } catch (err) { + // Read-only node_modules (CI caches, sandboxes) lands here. The app still + // runs; only the displayed name keeps saying "Electron". + warn(`Could not patch ${plistPath}: ${err.message}`); + return; + } + + // Stay quiet when there was nothing to do, so repeat runs don't add noise. + if (bundle.renamed || stale.length > 0) { + console.log( + `[brand-dev-electron] Dev Electron bundle now identifies as "${productName}".`, + ); + } +} + +main(); diff --git a/scripts/check-sdk-version-sync.mjs b/scripts/check-sdk-version-sync.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e89178275163ce96116dd85d7c701a035d02c5b0 --- /dev/null +++ b/scripts/check-sdk-version-sync.mjs @@ -0,0 +1,500 @@ +#!/usr/bin/env node + +/** + * Check SDK Version Sync + * + * Verifies two things against versions.agentServer in config/defaults.json: + * + * 1. The local @openhands/typescript-client pin in package.json. Canvas renders + * the ACP provider picker from that generated registry mirror but launches + * the adapter through the agent-server image, so a skew ships a picker + * offering models and launch commands agent-server does not implement. + * + * 2. That the released automation package (openhands-automation on PyPI) + * uses the SDK version expected for that automation release for all agent SDK libraries: + * - openhands-sdk + * - openhands-tools + * - openhands-workspace + * - openhands-agent-server + * + * This script checks the RELEASED PyPI version of openhands-automation (as specified + * by versions.automation in config/defaults.json), not the main branch. + * The expected SDK dependency version is versions.agentServer — the two must + * always match, so this script catches any drift. + * + * This script is run in CI to catch version drift between projects. + * + * Usage: + * node scripts/check-sdk-version-sync.mjs + * EXPECTED_SDK_VERSION=1.46.0 node scripts/check-sdk-version-sync.mjs + * node scripts/check-sdk-version-sync.mjs --check-pypi + * + * Environment variables: + * EXPECTED_SDK_VERSION - Override the expected version (instead of reading from config/defaults.json) + * AUTOMATION_PACKAGE_NAME - Override the automation package name (default: openhands-automation) + * AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from config/defaults.json) + * + * Options: + * --check-pypi Also check the latest SDK version on PyPI + * --help Show help + * + * Exit codes: + * 0 - All SDK versions match + * 1 - Version mismatch detected or error occurred + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +// Parse command line arguments +const args = process.argv.slice(2); +const checkPyPI = args.includes("--check-pypi"); +const showHelp = args.includes("--help") || args.includes("-h"); + +if (showHelp) { + console.log(` +SDK Version Sync Check + +Verifies that the released openhands-automation package on PyPI uses the +SDK version expected for that automation release. + +The automation version is read from config/defaults.json (versions.automation). +The expected SDK dependency version is read from versions.agentServer. + +Usage: + node scripts/check-sdk-version-sync.mjs [options] + +Options: + --check-pypi Also check the latest SDK version on PyPI + --help, -h Show this help + +Environment variables: + EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from config/defaults.json) + AUTOMATION_PACKAGE_NAME Override the automation package name (default: openhands-automation) + AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from config/defaults.json) + +Triggering from other repos: + The automation repo or SDK repo can trigger this check via GitHub repository_dispatch: + + curl -X POST \\ + -H "Authorization: token \$GITHUB_TOKEN" \\ + -H "Accept: application/vnd.github.v3+json" \\ + https://api.github.com/repos/OpenHands/OpenHands/dispatches \\ + -d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.46.0"}}' +`); + process.exit(0); +} + +// ANSI color codes for terminal output +const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + dim: "\x1b[2m", +}; + +// SDK packages that must have matching versions +const SDK_PACKAGES = [ + "openhands-sdk", + "openhands-tools", + "openhands-workspace", + "openhands-agent-server", +]; + +// Mirrors the SDK's ACP provider registry. Must track versions.agentServer: +// the picker is rendered from this pin but the adapter is launched by that +// image, so a skew advertises models the running agent-server cannot run. +const CLIENT_PACKAGE_NAME = "@openhands/typescript-client"; + +// Configurable automation package (can be overridden via env) +const AUTOMATION_PACKAGE_NAME = process.env.AUTOMATION_PACKAGE_NAME || "openhands-automation"; + +// Default retry configuration +const RETRY_COUNT = 3; +const RETRY_DELAY_MS = 1000; + +/** + * Normalize a version string for comparison. + * Handles variations like "1.22" vs "1.22.0" by ensuring consistent format. + */ +function normalizeVersion(version) { + if (!version) return null; + + // Remove any pre-release or build metadata for base comparison + const baseVersion = version.split(/[-+]/)[0]; + + // Split into parts and pad to 3 parts (major.minor.patch) + const parts = baseVersion.split(".").map((p) => parseInt(p, 10) || 0); + while (parts.length < 3) { + parts.push(0); + } + + return parts.slice(0, 3).join("."); +} + +/** + * Compare two versions for equality (handles semantic equivalence) + */ +function versionsEqual(v1, v2) { + return normalizeVersion(v1) === normalizeVersion(v2); +} + +/** + * Sleep for a given number of milliseconds + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ── Centralized config ────────────────────────────────────────────────────── +let SHARED_DEFAULTS; +try { + SHARED_DEFAULTS = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), + ); + if (!SHARED_DEFAULTS.versions?.agentServer) { + throw new Error("missing required field: versions.agentServer"); + } +} catch (err) { + console.error(`${colors.red}Failed to load config/defaults.json: ${err.message}${colors.reset}`); + console.error("Ensure the file exists and contains valid JSON with required fields."); + process.exit(1); +} + +/** + * Read the expected automation SDK dependency version from environment + * or config/defaults.json. + */ +function getExpectedVersion() { + // Allow override via environment variable (useful for CI triggers). + const envVersion = process.env.EXPECTED_SDK_VERSION; + if (envVersion && envVersion.trim()) { + return { version: envVersion.trim(), source: "EXPECTED_SDK_VERSION env var" }; + } + + return { + version: SHARED_DEFAULTS.versions.agentServer, + source: "config/defaults.json (versions.agentServer)", + }; +} + +/** + * Compare the local typescript-client pin against the expected SDK version. + * Returns a mismatch descriptor, or null when they agree. + */ +function findClientPinMismatch(pinnedVersion, expectedVersion) { + if (!pinnedVersion) { + return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: null }; + } + // A range would reintroduce the skew this check exists to catch. + if (!/^[0-9]/.test(pinnedVersion)) { + return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: pinnedVersion }; + } + if (versionsEqual(pinnedVersion, expectedVersion)) { + return null; + } + return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: pinnedVersion }; +} + +/** + * Read the typescript-client pin from package.json. + */ +function readClientPin() { + const pkg = JSON.parse( + readFileSync(join(projectRoot, "package.json"), "utf-8"), + ); + return pkg.dependencies?.[CLIENT_PACKAGE_NAME] ?? null; +} + +/** + * Fetch the latest version of a package from PyPI + */ +async function fetchPyPIVersion(packageName) { + const url = `https://pypi.org/pypi/${packageName}/json`; + try { + const response = await fetch(url); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data.info?.version || null; + } catch { + return null; + } +} + +/** + * Read the automation version from env var or config/defaults.json + */ +function getAutomationVersion() { + // Allow override via environment variable + const envVersion = process.env.AUTOMATION_PACKAGE_VERSION; + if (envVersion && envVersion.trim()) { + return { version: envVersion.trim(), source: "AUTOMATION_PACKAGE_VERSION env var" }; + } + + return { + version: SHARED_DEFAULTS.versions.automation, + source: "config/defaults.json (versions.automation)", + }; +} + +/** + * Fetch package metadata from PyPI and extract dependencies (with retry) + */ +async function fetchPyPIDependencies(packageName, version) { + const url = `https://pypi.org/pypi/${packageName}/${version}/json`; + + console.log(`${colors.dim}Fetching ${url}${colors.reset}`); + + let lastError; + for (let attempt = 0; attempt < RETRY_COUNT; attempt++) { + try { + const response = await fetch(url); + + // 404 is a config issue, don't retry + if (response.status === 404) { + throw new Error( + `Package ${packageName}==${version} not found on PyPI (404). Check the package name and version.`, + ); + } + + if (!response.ok) { + throw new Error( + `Failed to fetch ${packageName}==${version} from PyPI: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.info?.requires_dist || []; + } catch (err) { + lastError = err; + + // Don't retry on 404 (config issue) + if (err.message.includes("not found on PyPI (404)")) { + throw err; + } + + // Retry on other errors (network issues, 5xx, etc.) + if (attempt < RETRY_COUNT - 1) { + const delay = RETRY_DELAY_MS * (attempt + 1); + console.log( + `${colors.yellow}Retry ${attempt + 1}/${RETRY_COUNT - 1} after ${delay}ms...${colors.reset}`, + ); + await sleep(delay); + } + } + } + + throw lastError; +} + +/** + * Parse PyPI requires_dist array and extract SDK package versions + * + * PyPI returns dependencies in PEP 508 format like: + * "openhands-sdk>=1.46.0,<2.0.0" + * "openhands-tools==1.46.0" + * "openhands-workspace (>=1.46.0)" + */ +function parseSdkVersionsFromRequiresDist(requiresDist) { + const versions = {}; + + for (const pkg of SDK_PACKAGES) { + for (const dep of requiresDist) { + // Check if the dependency starts with our package name + // The package name may be followed by whitespace, operators, or parentheses + if (!dep.toLowerCase().startsWith(pkg.toLowerCase())) { + continue; + } + + // Extract the version number - look for patterns like: + // ">=1.46.0", "==1.46.0", "(>=1.46.0)", "~=1.46.0" + // After the package name and before any comma or closing paren + const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/; + const match = dep.match(versionPattern); + if (match) { + versions[pkg] = match[1]; + break; + } + } + } + + return versions; +} + +/** + * Main entry point + */ +async function main() { + console.log(""); + console.log( + `${colors.cyan}SDK Version Sync Check${colors.reset}`, + ); + console.log("─".repeat(50)); + console.log(""); + + try { + // Get expected version from env var or config/defaults.json + const { version: expectedVersion, source: versionSource } = getExpectedVersion(); + console.log( + `Expected automation SDK version: ${colors.green}${expectedVersion}${colors.reset} (from ${versionSource})`, + ); + + // Offline, so it runs first and fails fast without the PyPI round trip. + const clientMismatch = findClientPinMismatch(readClientPin(), expectedVersion); + if (clientMismatch) { + console.log(""); + console.log( + ` ${CLIENT_PACKAGE_NAME.padEnd(30)} ${colors.red}✗ ${clientMismatch.actual ?? "(absent)"} (expected ${expectedVersion})${colors.reset}`, + ); + console.log(""); + console.log(`${colors.red}Version mismatch detected!${colors.reset}`); + console.log(""); + console.log( + `${CLIENT_PACKAGE_NAME} mirrors the SDK's ACP provider registry that Canvas renders the`, + ); + console.log( + `ACP picker from, but the adapter is launched by agent-server ${expectedVersion}. A skew ships a`, + ); + console.log("picker offering models and launch commands that agent-server does not implement."); + console.log(""); + console.log("To fix, update one of the following:"); + console.log(` 1. Pin ${CLIENT_PACKAGE_NAME} to ${expectedVersion} in package.json`); + console.log(" 2. Update versions.agentServer in config/defaults.json"); + console.log(""); + process.exit(1); + } + console.log( + `Client registry pin: ${colors.green}${CLIENT_PACKAGE_NAME}@${expectedVersion}${colors.reset} (matches versions.agentServer)`, + ); + + // Get automation version from env var or config/defaults.json + const { version: automationVersion, source: automationSource } = getAutomationVersion(); + console.log( + `Automation package: ${colors.cyan}${AUTOMATION_PACKAGE_NAME}==${automationVersion}${colors.reset} (from ${automationSource})`, + ); + + // Optionally check PyPI for the latest SDK version + if (checkPyPI) { + console.log(""); + console.log("Checking latest SDK versions on PyPI:"); + for (const pkg of SDK_PACKAGES) { + const pypiVersion = await fetchPyPIVersion(pkg); + if (pypiVersion) { + const status = versionsEqual(pypiVersion, expectedVersion) + ? colors.green + : colors.yellow; + console.log(` ${pkg.padEnd(25)} ${status}${pypiVersion}${colors.reset}`); + } else { + console.log(` ${pkg.padEnd(25)} ${colors.dim}(not found on PyPI)${colors.reset}`); + } + } + } + + console.log(""); + + // Fetch automation package dependencies from PyPI + const requiresDist = await fetchPyPIDependencies(AUTOMATION_PACKAGE_NAME, automationVersion); + const automationVersions = parseSdkVersionsFromRequiresDist(requiresDist); + + // Check each SDK package + let hasErrors = false; + let foundAny = false; + const mismatches = []; + + console.log(`Checking ${AUTOMATION_PACKAGE_NAME}==${automationVersion} SDK dependencies:`); + console.log(""); + + for (const pkg of SDK_PACKAGES) { + const actualVersion = automationVersions[pkg]; + + if (actualVersion) { + foundAny = true; + if (versionsEqual(actualVersion, expectedVersion)) { + console.log( + ` ${pkg.padEnd(25)} ${colors.green}✓ ${actualVersion}${colors.reset}`, + ); + } else { + hasErrors = true; + console.log( + ` ${pkg.padEnd(25)} ${colors.red}✗ ${actualVersion} (expected ${expectedVersion})${colors.reset}`, + ); + mismatches.push({ + package: pkg, + expected: expectedVersion, + actual: actualVersion, + }); + } + } else { + // Package not found - might be a transitive dependency, not an error + console.log( + ` ${pkg.padEnd(25)} ${colors.dim}- not a direct dependency${colors.reset}`, + ); + } + } + + console.log(""); + + if (!foundAny) { + console.log( + `${colors.yellow}Warning: No SDK packages found in ${AUTOMATION_PACKAGE_NAME}==${automationVersion} dependencies${colors.reset}`, + ); + console.log("This might indicate a parsing issue or the package is not yet published."); + console.log(""); + process.exit(1); + } + + if (hasErrors) { + console.log( + `${colors.red}Version mismatch detected!${colors.reset}`, + ); + console.log(""); + console.log(`The released ${AUTOMATION_PACKAGE_NAME}==${automationVersion} uses different SDK versions than expected for that automation release.`); + console.log(""); + console.log("Mismatched packages:"); + for (const m of mismatches) { + console.log(` - ${m.package}: ${m.actual} (expected ${m.expected})`); + } + console.log(""); + console.log("To fix, update one of the following:"); + console.log( + ` 1. Release a new version of ${AUTOMATION_PACKAGE_NAME} with SDK dependencies pinned to ${expectedVersion}`, + ); + console.log( + ` 2. Update versions.automation in config/defaults.json to a newer release`, + ); + console.log(""); + process.exit(1); + } + + console.log( + `${colors.green}All SDK versions are in sync!${colors.reset}`, + ); + console.log(""); + } catch (error) { + console.error(`${colors.red}Error: ${error.message}${colors.reset}`); + process.exit(1); + } +} + +// Export for testing +export { + normalizeVersion, + versionsEqual, + parseSdkVersionsFromRequiresDist, + findClientPinMismatch, + readClientPin, + SDK_PACKAGES, + CLIENT_PACKAGE_NAME, + AUTOMATION_PACKAGE_NAME, +}; + +main(); diff --git a/scripts/check-translation-completeness.cjs b/scripts/check-translation-completeness.cjs new file mode 100644 index 0000000000000000000000000000000000000000..52cbc4a2b05db496bb5123636a4cf5486f04de3e --- /dev/null +++ b/scripts/check-translation-completeness.cjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +/** + * Pre-commit hook script to check for translation completeness + * This script ensures that all translation keys have entries for all supported languages + * and that values are actually translated rather than English copied to every language. + */ + +const fs = require('fs'); +const path = require('path'); + +// Keys whose value is intentionally identical in every language (brand names, +// protocol/technical terms, placeholder-only format strings). Add a key here +// only when the English value is genuinely correct for all languages. +const IDENTICAL_VALUE_ALLOWLIST = new Set([ + 'ACTION_MESSAGE$ACP_TOOL', + 'API$TAVILY_KEY_EXAMPLE', + 'API$TVLY_KEY_EXAMPLE', + 'AUTOMATIONS$DOWNLOAD_TARBALL', + 'AUTOMATIONS$GIT_SYNC$BRANCH_PLACEHOLDER', + 'AUTOMATIONS$GIT_SYNC$PATH_PLACEHOLDER', + 'AUTOMATIONS$GIT_SYNC$REPO_URL_PLACEHOLDER', + 'BACKEND$CLOUD_TITLE', + 'BACKEND$VERSION_LABEL', + 'BRANDING$OPENHANDS', + 'COMMAND_MENU$SHORTCUT', + 'CONVERSATION$ACP_AGENT_GENERIC', + 'CONVERSATION$BUDGET_USAGE_FORMAT', + 'CONVERSATION$OVERVIEW_DIFF_ADDITIONS', + 'CONVERSATION$OVERVIEW_DIFF_DELETIONS', + 'CONVERSATION$OVERVIEW_GIT', + 'CONVERSATION$OVERVIEW_UNAVAILABLE', + 'CONVERSATION_PANEL$PREVIEW_GIT', + 'FILES$VSCODE', + 'GITHUB$AUTH_SCOPE', + 'LAUNCH$PLUGIN_PATH', + 'LAUNCH$PLUGIN_REF', + 'SCHEMA$LLM$SECTION_LABEL', + 'SCHEMA$LLM$TOP_K$LABEL', + 'SCHEMA$LLM$TOP_P$LABEL', + 'SCHEMA$SECURITY_ANALYZER$CHOICE$LLM', + 'SCHEMA$VERIFICATION$SECURITY_ANALYZER$CHOICE$LLM', + 'SETTINGS$AGENT_SERVER_URL_PLACEHOLDER', + 'SETTINGS$AGENT_TYPE_OPENHANDS', + 'SETTINGS$APP_UPDATE_CARD_TITLE', + 'SETTINGS$AZURE_DEVOPS', + 'SETTINGS$CLOUD_SETTINGS_LINK', + 'SETTINGS$VERSION_DOCKER', + 'SETTINGS$VERSION_NPM_RECOMMENDED', + 'SETTINGS$VERSION_PRODUCT_NAME', + 'SETTINGS$GITHUB', + 'SETTINGS$GITLAB', + 'SETTINGS$MCP_AUTH_MODE_OAUTH', + 'SETTINGS$MCP_DEFAULT_CONFIG', + 'SETTINGS$MCP_HEADERS_PLACEHOLDER', + 'SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER', + 'SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER', + 'SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER', + 'SETTINGS$MCP_SERVER_TYPE_SHTTP', + 'SETTINGS$MCP_SERVER_TYPE_SSE', + 'SETTINGS$MCP_SERVER_TYPE_STDIO', + 'SETTINGS$NAV_LLM', + 'SETTINGS$OPENHANDS_API_KEY_HELP_LINK', + 'SETTINGS$SKILLS_PILLS_MORE', + 'SETTINGS$SKILLS_VERSION', + 'SETTINGS$SLACK', + 'SETTINGS$TITLE_GENERATION_PROFILE_OPTION', + 'SETUP$REPOSITORY_PLACEHOLDER', + 'VSCODE$TITLE', + 'WORKSPACE$JUPYTER_TAB_LABEL', +]); + +// Extract the language codes from the AvailableLanguages array in the i18n index file +function getSupportedLanguageCodes() { + const i18nIndexPath = path.join(__dirname, '../src/i18n/index.ts'); + const i18nIndexContent = fs.readFileSync(i18nIndexPath, 'utf8'); + + const languageCodesRegex = /\{ label: "[^"]+", value: "([^"]+)" \}/g; + const supportedLanguageCodes = []; + let match; + + while ((match = languageCodesRegex.exec(i18nIndexContent)) !== null) { + supportedLanguageCodes.push(match[1]); + } + + return supportedLanguageCodes; +} + +// Check each translation key for missing languages, extra languages, and +// untranslated (English-copied) values +function checkTranslations(translationJson, supportedLanguageCodes) { + const missingTranslations = {}; + const extraLanguages = {}; + const untranslatedKeys = {}; + + const nonEnglishLanguageCodes = supportedLanguageCodes.filter( + (langCode) => langCode !== 'en' + ); + + Object.entries(translationJson).forEach(([key, translations]) => { + // Get the languages available for this key + const availableLanguages = Object.keys(translations); + + // Find missing languages for this key + const missing = supportedLanguageCodes.filter( + (langCode) => !availableLanguages.includes(langCode) + ); + + if (missing.length > 0) { + missingTranslations[key] = missing; + } + + // Find extra languages for this key + const extra = availableLanguages.filter( + (langCode) => !supportedLanguageCodes.includes(langCode) + ); + + if (extra.length > 0) { + extraLanguages[key] = extra; + } + + // Flag keys where every non-English value is the English value copied + // verbatim — a strong signal the key was never translated. Keys whose value + // is legitimately identical everywhere belong in IDENTICAL_VALUE_ALLOWLIST. + if ( + !IDENTICAL_VALUE_ALLOWLIST.has(key) && + translations.en !== undefined && + nonEnglishLanguageCodes.every( + (langCode) => translations[langCode] === translations.en + ) + ) { + untranslatedKeys[key] = translations.en; + } + }); + + return { missingTranslations, extraLanguages, untranslatedKeys }; +} + +module.exports = { + IDENTICAL_VALUE_ALLOWLIST, + getSupportedLanguageCodes, + checkTranslations, +}; + +if (require.main === module) { + // Load the translation file + const translationJsonPath = path.join(__dirname, '../src/i18n/translation.json'); + const translationJson = require(translationJsonPath); + + const { missingTranslations, extraLanguages, untranslatedKeys } = + checkTranslations(translationJson, getSupportedLanguageCodes()); + + const hasErrors = + Object.keys(missingTranslations).length > 0 || + Object.keys(extraLanguages).length > 0 || + Object.keys(untranslatedKeys).length > 0; + + // Generate detailed error message if there are missing translations + if (Object.keys(missingTranslations).length > 0) { + console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected'); + console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`); + + Object.entries(missingTranslations).forEach(([key, langs]) => { + console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`); + }); + + console.error('\nPlease add the missing translations before committing.'); + } + + // Generate detailed error message if there are extra languages + if (Object.keys(extraLanguages).length > 0) { + console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected'); + console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`); + + Object.entries(extraLanguages).forEach(([key, langs]) => { + console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`); + }); + + console.error('\nPlease remove the extra languages before committing.'); + } + + // Generate detailed error message if there are untranslated keys + if (Object.keys(untranslatedKeys).length > 0) { + console.error('\x1b[31m%s\x1b[0m', 'ERROR: Untranslated keys detected'); + console.error(`Found ${Object.keys(untranslatedKeys).length} translation keys where the English value is copied to every language:`); + + Object.entries(untranslatedKeys).forEach(([key, value]) => { + console.error(`- Key "${key}" has the same value ("${value}") for all languages`); + }); + + console.error('\nPlease translate the values before committing. If a value is intentionally identical in every language (brand name, technical term, format string), add the key to IDENTICAL_VALUE_ALLOWLIST in scripts/check-translation-completeness.cjs.'); + } + + // Exit with error code if there are issues + if (hasErrors) { + process.exit(1); + } else { + console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!'); + } +} diff --git a/scripts/dev-extra-backend.mjs b/scripts/dev-extra-backend.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b6588b57811d65bb8811771de12692e3048c673f --- /dev/null +++ b/scripts/dev-extra-backend.mjs @@ -0,0 +1,262 @@ +import { spawn } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +import { + buildAgentServerCommand, + buildAgentServerEnv, + buildSafeDevConfig, + formatMissingUvxGuidance, + validateLocalAgentServerPath, +} from "./dev-safe.mjs"; +import { + getProcessTreeSpawnOptions, + isProcessRunning, + signalProcessTree, +} from "./dev-process-utils.mjs"; + +const DEFAULT_EXTRA_BACKEND_PORT = 18002; +const DEFAULT_EXTRA_VSCODE_PORT = 18003; +const DEFAULT_WAIT_TIMEOUT_MS = 30_000; + +function parsePort(value, fallback) { + if (value == null || value === "") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid port: ${value}`); + } + + return parsed; +} + +/** + * Build a config for an *extra* standalone agent-server that shares the + * bundled instance's persistence (state dir, conversations, secret key) + * but listens on a different backend + vscode port. + * + * @param {string} cwd + * @param {Record} env + */ +export function buildExtraBackendConfig( + cwd = process.cwd(), + env = process.env, +) { + const base = buildSafeDevConfig(cwd, env); + + const backendPort = parsePort( + env.OH_CANVAS_EXTRA_BACKEND_PORT, + DEFAULT_EXTRA_BACKEND_PORT, + ); + const vscodePort = parsePort( + env.OH_CANVAS_EXTRA_VSCODE_PORT, + DEFAULT_EXTRA_VSCODE_PORT, + ); + + return { + ...base, + backendPort, + vscodePort, + backendBaseUrl: `http://127.0.0.1:${backendPort}`, + backendHost: `127.0.0.1:${backendPort}`, + }; +} + +function isEnoentError(error) { + return Boolean( + (error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT") || + /ENOENT/.test(String(error)), + ); +} + +async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Keep polling until timeout. + } + + await delay(500); + } + + throw new Error(`Timed out waiting for agent-server at ${url}`); +} + +function spawnProcess(command, args, options = {}) { + const child = spawn( + command, + args, + getProcessTreeSpawnOptions({ + stdio: "inherit", + ...options, + }), + ); + + child.once("error", (error) => { + if (isEnoentError(error) && command === "uvx") { + console.error(formatMissingUvxGuidance(options?.cwd)); + } else if (isEnoentError(error)) { + console.error( + `Failed to start ${command}. Make sure it is installed and on your PATH.`, + ); + } else { + console.error(`Failed to start ${command}:`, error); + } + }); + + return child; +} + +async function main() { + const config = buildExtraBackendConfig(); + + if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { + validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); + } + + for (const dir of [ + config.stateDir, + config.tmuxTmpDir, + config.conversationsPath, + config.workspacesPath, + config.bashEventsDir, + ]) { + mkdirSync(dir, { recursive: true }); + } + + const agentServerCmd = buildAgentServerCommand(); + + const secretKeySource = process.env.OH_SECRET_KEY + ? "custom (from OH_SECRET_KEY)" + : "default (for local development)"; + + console.log("Starting EXTRA standalone agent-server (shared state)..."); + console.log(`- agent-server: ${agentServerCmd.source}`); + console.log(`- backend: ${config.backendBaseUrl}`); + console.log(`- vscode port: ${config.vscodePort}`); + console.log(`- shared state dir: ${config.stateDir}`); + console.log(`- shared conversations: ${config.conversationsPath}`); + console.log(`- secret key: ${secretKeySource}`); + console.log(""); + 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.", + ); + console.log(""); + + const backend = spawnProcess( + agentServerCmd.command, + [ + ...agentServerCmd.args, + "--host", + "127.0.0.1", + "--port", + String(config.backendPort), + ], + { + cwd: config.cwd, + env: { + // Deliberately not opting into the editor path prefix. This server is + // reached by registering it as an extra backend from a browser whose + // origin belongs to some *other* stack, so a prefix on that origin + // either does not resolve or — worse — resolves to the bundled + // stack's editor, silently handing back a different container's + // workspace. No single global prefix can disambiguate the two, so this + // launcher stays out of prefix-mode; the editor button is unavailable + // for conversations on an extra backend. + ...process.env, + ...buildAgentServerEnv(config), + }, + }, + ); + + let shuttingDown = false; + + const shutdown = (signal = "SIGTERM") => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + signalProcessTree(backend, signal); + + setTimeout(() => { + if (isProcessRunning(backend)) { + signalProcessTree(backend, "SIGKILL"); + } + process.exit(process.exitCode ?? 0); + }, 3000); + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + // The agent-server is spawned detached, so a SIGHUP that kills this launcher + // (terminal or multiplexer death) would otherwise leave it running and holding + // its port. Forward SIGTERM rather than SIGHUP: uvicorn only handles + // SIGINT/SIGTERM, so a forwarded SIGHUP would terminate the agent-server by + // default action instead of shutting it down gracefully. + process.on("SIGHUP", () => shutdown("SIGTERM")); + + const backendErrored = new Promise((_, reject) => { + backend.once("error", (error) => reject(error)); + }); + const backendExited = new Promise((_, reject) => { + backend.once("exit", (code, signal) => { + if (!shuttingDown) { + reject( + new Error( + `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`, + ), + ); + } + }); + }); + + try { + await Promise.race([ + waitForServer(`${config.backendBaseUrl}/server_info`), + backendErrored, + backendExited, + ]); + } catch (error) { + shutdown(); + throw error; + } + + console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`); + + backend.once("exit", (code) => { + if (!shuttingDown) { + console.error(`agent-server exited unexpectedly with code ${code ?? 0}`); + shutdown(); + process.exitCode = code ?? 1; + } else { + process.exitCode = code ?? 0; + } + }); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/scripts/dev-process-utils.mjs b/scripts/dev-process-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d38796949d1a3e901fc6aa5f6cf1fba0045863ec --- /dev/null +++ b/scripts/dev-process-utils.mjs @@ -0,0 +1,150 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +/** + * Return true while Node still considers the child process active. + * + * Do not use ChildProcess#killed for cleanup decisions. In Node, `killed` + * only means a signal was sent successfully; it does not mean the process has + * exited. That distinction matters for dev launchers because uvx/npm + * wrappers can receive SIGTERM while their long-running child process keeps + * serving on the original port. + */ +export function isProcessRunning(proc) { + return proc.exitCode === null && proc.signalCode === null; +} + +/** + * Add spawn options needed for safe service launches and process-tree cleanup. + * + * Arguments must bypass shell parsing so values such as version constraints + * containing `<` are forwarded literally. Callers that need shell behavior + * must invoke the shell explicitly as the command. + * + * On POSIX, `detached: true` makes the spawned service the leader of a new + * process group. Later we can signal `-pid` to terminate that whole group, + * including wrapper chains like: + * + * launcher -> uvx -> python agent-server + * launcher -> npm -> sh -> Vite + * + * Windows does not support POSIX process groups, so callers fall back to + * signaling the direct child process there. + */ +export function getProcessTreeSpawnOptions(options = {}) { + return { + ...options, + shell: false, + detached: process.platform !== "win32", + }; +} + +/** + * Resolve a service command to a directly spawnable target on Windows. + * + * Services spawn without a shell so argument values reach the child verbatim. + * Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like + * `agent-client-protocol<0.11` is read as `<` input redirection and the spawn + * dies with "The system cannot find the file specified." Resolving to an + * absolute path lets callers spawn it shell-free. + * + * Returns `command` unchanged off Windows, when already a path, or if the lookup + * fails. + */ +export function resolveWindowsCommand( + command, + platform = process.platform, + lookup = whereCommandLookup, +) { + if (platform !== "win32") { + return command; + } + if (command.includes("/") || command.includes("\\")) { + return command; + } + return lookup(command) || command; +} + +function whereCommandLookup(command) { + const result = spawnSync("where.exe", [command], { encoding: "utf8" }); + if (result.status !== 0 || !result.stdout) { + return null; + } + return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null; +} + +/** + * Signal the whole spawned service tree when possible. + * + * POSIX `process.kill(-pid, signal)` targets the process group whose id is + * `pid`; this only works because services are spawned with + * `getProcessTreeSpawnOptions()`. Without the negative pid, shutdown would + * often stop only the wrapper process and leave the actual server child + * listening on its port. + */ +export function signalProcessTree(proc, signal) { + if (!isProcessRunning(proc)) { + return false; + } + + try { + if (process.platform === "win32" && proc.pid) { + killWindowsProcessTree(proc, signal); + } else if (!proc.pid) { + proc.kill(signal); + } else { + process.kill(-proc.pid, signal); + } + return true; + } catch (err) { + if (err?.code === "ESRCH") { + return false; + } + throw err; + } +} + +/** + * Windows has no POSIX process groups: ChildProcess#kill reaches only the + * direct child (e.g. the uvx wrapper), leaving grandchildren — the actual + * python agent-server holding its port — running. `taskkill /t` walks the + * child tree instead. Windows also has no graceful tree signal (taskkill + * without /f posts WM_CLOSE, which console processes ignore), so SIGTERM and + * SIGKILL both map to the same forceful /f kill; callers' delayed SIGKILL + * pass skips already-exited trees via isProcessRunning, so the repeat is a + * no-op. A non-zero taskkill exit just means the tree already exited — only + * a failure to spawn taskkill itself falls back to the direct kill. + */ +function killWindowsProcessTree(proc, signal) { + const result = spawnSync( + "taskkill", + ["/pid", String(proc.pid), "/t", "/f"], + // windowsHide avoids a console window flash when invoked from the + // packaged (GUI) Electron process. + { stdio: "ignore", windowsHide: true }, + ); + if (result.error) { + proc.kill(signal); + } +} + +export function createShutdownHookRegistry(onError) { + const hooks = new Set(); + + return { + add(hook) { + hooks.add(hook); + return () => hooks.delete(hook); + }, + + run() { + for (const hook of hooks) { + try { + hook(); + } catch (err) { + onError?.(err); + } + } + }, + }; +} diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs new file mode 100644 index 0000000000000000000000000000000000000000..512ef71cae8c978f72775b69f816baad782ccd66 --- /dev/null +++ b/scripts/dev-safe.mjs @@ -0,0 +1,1217 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import net from "node:net"; +import { homedir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + getProcessTreeSpawnOptions, + isProcessRunning, + signalProcessTree, +} from "./dev-process-utils.mjs"; +// buildRuntimeServicesInfo moved to its own dependency-free module so the +// Docker entrypoint can run it as a CLI. Re-exported below for back-compat +// (dev-with-automation.mjs and tests still import it from here). +import { buildRuntimeServicesInfo } from "./runtime-services-info.mjs"; +import { fileLog, stripAnsi } from "./logger.mjs"; + +// ── Centralized config (single source of truth for versions, ports, etc.) ─── +const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHARED_DEFAULTS = JSON.parse( + readFileSync( + path.join(__dev_safe_dirname, "..", "config", "defaults.json"), + "utf-8", + ), +); + +const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; +// Path prefix the bundled editor is served under. The same value has to reach +// agent-server (as OH_VSCODE_BASE_PATH, so openvscode-server is launched with +// --server-base-path and advertises the prefix) and the ingress route table, +// or the advertised URL and the route that serves it disagree. +export const VSCODE_BASE_PATH = SHARED_DEFAULTS.paths.vscodeBasePath; +const DEFAULT_VITE_PORT = 3001; +const DEFAULT_WAIT_TIMEOUT_MS = 30_000; +const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer; +const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk"; +const LOCAL_AGENT_SERVER_SUBDIRS = [ + "openhands-agent-server", + "openhands-sdk", + "openhands-tools", + "openhands-workspace", +]; +const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer; +// Temporary transitive-dep pin: openhands-sdk 1.40.1 leaves agent-client-protocol +// unbounded (>=0.10.1), but acp 0.11.0 reordered the ACP prompt() args and breaks +// the SDK's ACP client. Hold acp <0.11 until a fixed SDK ships. See config/defaults.json. +const AGENT_CLIENT_PROTOCOL_CONSTRAINT = + SHARED_DEFAULTS.constraints?.agentClientProtocol; +const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY = + SHARED_DEFAULTS.telemetry.posthogApiKey; +const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST = + SHARED_DEFAULTS.telemetry.posthogHost; +const AGENT_SERVER_POSTHOG_CONSTRAINT = "posthog>=6,<7"; +const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"]; + +/** + * Generate a cryptographically secure random API key. + * Returns a 64-character hex string (256-bit). + */ +export function generateRandomApiKey() { + return randomBytes(32).toString("hex"); +} + +// Where the auto-generated 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`. +// +// To rotate the key, delete this file. To pin a key explicitly, export +// LOCAL_BACKEND_API_KEY — it takes precedence over the persisted file. +export const DEFAULT_API_KEY_PATH = path.join( + homedir(), + ".openhands", + "agent-canvas", + "api-key.txt", +); + +/** @deprecated Use DEFAULT_API_KEY_PATH */ +export const DEFAULT_SESSION_API_KEY_PATH = DEFAULT_API_KEY_PATH; + +// Where the OH_SECRET_KEY is persisted so dev mode and Docker mode share the +// same encryption key when both use ~/.openhands as their state directory. +// docker/entrypoint.sh reads and writes this same file, so whichever mode runs +// first generates the key and the other picks it up automatically. +// +// To rotate the key, delete this file and restart both modes. To pin a key +// explicitly, export OH_SECRET_KEY — that takes precedence over the file. +export const DEFAULT_SECRET_KEY_PATH = path.join( + homedir(), + ".openhands", + "agent-canvas", + "secret-key.txt", +); + +// Cache so repeated lookups within a single process return the same key, +// keyed by file path so tests can use temp paths in isolation. +const persistedApiKeyCache = new Map(); + +/** + * Load the persisted default 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) API key. + */ +export function getOrCreatePersistedApiKeyFile( + filePath = DEFAULT_API_KEY_PATH, +) { + return getOrCreatePersistedApiKey(filePath, "session"); +} + +/** @deprecated Use getOrCreatePersistedApiKeyFile */ +export function getOrCreatePersistedSessionApiKey( + filePath = DEFAULT_API_KEY_PATH, +) { + return getOrCreatePersistedApiKeyFile(filePath); +} + +/** + * Load a persisted default 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. + * @param {string} label - Human-readable key label for warning messages. + * @returns {string} The (hex) API key. + */ +export function getOrCreatePersistedApiKey(filePath, label = "API") { + const cached = persistedApiKeyCache.get(filePath); + if (cached) return cached; + + // Try to read an existing key. + try { + const existing = readFileSync(filePath, "utf8").trim(); + if (existing) { + persistedApiKeyCache.set(filePath, existing); + return existing; + } + // File exists but is empty -- treat as if missing and regenerate. + } catch (error) { + if (!isEnoentError(error)) { + console.warn( + `Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`, + ); + } + } + + // Generate and persist a new key. + const newKey = generateRandomApiKey(); + try { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 }); + } catch (error) { + console.warn( + `Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`, + ); + } + persistedApiKeyCache.set(filePath, newKey); + return newKey; +} + +/** + * Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}. + * Intended for tests that swap the persisted file path between cases. + */ +export function resetPersistedSessionApiKeyCache() { + persistedApiKeyCache.clear(); +} + +function isEnoentError(error) { + return Boolean( + (error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT") || + /ENOENT/.test(String(error)), + ); +} + +/** + * Find a free port, preferring the specified port if available. + * + * Tries the preferred port first; if it's busy, falls back to letting + * the OS assign any available port. This preserves predictable defaults + * while gracefully handling port conflicts. + * + * **Note on race conditions:** There is a small window between when this + * function checks port availability and when the calling service actually + * binds to the port. During this window, another process could theoretically + * grab the port. This is an accepted limitation of the "check-then-use" + * approach. Callers (like agent-server) should handle EADDRINUSE gracefully. + * For Vite, `strictPort: true` ensures a fast failure if this occurs. + * + * @param {number} preferredPort - The port to try first + * @param {string} host - The host to bind to (default: "127.0.0.1") + * @returns {Promise} The actual port that was acquired + */ +export async function findFreePort(preferredPort, host = "127.0.0.1") { + // If preferredPort is 0, skip the check and go straight to OS assignment + if (preferredPort > 0) { + const preferredAvailable = await tryPort(preferredPort, host); + if (preferredAvailable) { + return preferredPort; + } + } + + // Fall back to OS-assigned port + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +/** + * Check if a port is available by attempting to bind to it. + * + * @param {number} port - The port to check + * @param {string} host - The host to bind to + * @returns {Promise} True if the port is available + */ +function tryPort(port, host = "127.0.0.1") { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** + * Assert that all listed ports are available, throwing a descriptive error if + * any are already in use. + * + * Intended as a pre-flight check before spawning services so that a concurrent + * agent-canvas instance is detected immediately rather than silently starting + * on a different port. + * + * @param {Array<{name: string, port: number}>} portConfigs - Named port list + * @param {string} [host] + */ +export async function assertPortsFree(portConfigs, host = "127.0.0.1") { + const results = await Promise.all( + portConfigs.map(async ({ name, port }) => ({ + name, + port, + free: await tryPort(port, host), + })), + ); + const busy = results.filter(({ free }) => !free); + if (busy.length === 0) return; + + const lines = busy + .map(({ name, port }) => ` • ${name}: port ${port}`) + .join("\n"); + throw new Error( + `Cannot start: the following ports are already in use:\n\n${lines}\n\n` + + `Another agent-canvas instance may already be running.\n` + + `Stop it first, or override the port via environment variables (e.g. PORT=).`, + ); +} + +/** + * Find multiple free ports at once, each preferring its specified default. + * + * Allocates ports sequentially to avoid race conditions between checks. + * + * @param {Array<{name: string, preferred: number}>} portConfigs - Port configurations + * @param {string} host - The host to bind to (default: "127.0.0.1") + * @returns {Promise>} Map of name to actual port + */ +export async function findFreePorts(portConfigs, host = "127.0.0.1") { + const result = {}; + const usedPorts = new Set(); + + for (const { name, preferred } of portConfigs) { + // Try preferred if not already taken by a previous allocation + // Skip if preferred is 0 (means "any port") or already used + if (preferred > 0 && !usedPorts.has(preferred)) { + const available = await tryPort(preferred, host); + if (available) { + result[name] = preferred; + usedPorts.add(preferred); + continue; + } + } + + // Fall back to OS-assigned port, retrying if we get a collision + let port; + let attempts = 0; + const maxAttempts = 100; + do { + port = await findFreePort(0, host); + if (++attempts > maxAttempts) { + throw new Error( + `Could not allocate unique port for "${name}" after ${maxAttempts} attempts`, + ); + } + } while (usedPorts.has(port)); + + result[name] = port; + usedPorts.add(port); + } + + return result; +} + +export function formatMissingUvxGuidance(cwd = process.cwd()) { + const readmePath = path.join(cwd, "README.md"); + + return [ + "Failed to start uvx. Make sure uv is installed and on your PATH.", + "", + "To fix this:", + "1. Install uv:", + " curl -LsSf https://astral.sh/uv/install.sh | sh", + "2. Make sure the uv bin dir is on your PATH:", + ' export PATH="$HOME/.local/bin:$PATH"', + " command -v uvx", + "", + "Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/", + `See the local Quickstart for details: ${readmePath}`, + "", + "Other options:", + "- npm run dev:frontend # use an already running backend", + "- npm run dev:mock # run the frontend with mock APIs", + ].join("\n"); +} + +function npmBinCandidates(binName, platform = process.platform) { + const candidates = [binName]; + if (platform === "win32") { + candidates.push(`${binName}.cmd`, `${binName}.ps1`); + } + return candidates; +} + +export function getMissingFrontendDependencyBins( + cwd = process.cwd(), + platform = process.platform, +) { + const binDir = path.join(cwd, "node_modules", ".bin"); + return FRONTEND_REQUIRED_BINS.filter( + (binName) => + !npmBinCandidates(binName, platform).some((candidate) => + existsSync(path.join(binDir, candidate)), + ), + ); +} + +export function formatMissingFrontendDependenciesGuidance( + missingBins, + cwd = process.cwd(), +) { + const missingList = missingBins.join(", "); + return [ + "Frontend dependencies are not installed or are incomplete.", + "", + `Missing npm binaries: ${missingList}`, + "", + "Run this from the repository root:", + " npm ci", + "", + `Repository root: ${cwd}`, + ].join("\n"); +} + +export function validateFrontendDependencies( + cwd = process.cwd(), + platform = process.platform, +) { + const missingBins = getMissingFrontendDependencyBins(cwd, platform); + if (missingBins.length > 0) { + throw new Error( + formatMissingFrontendDependenciesGuidance(missingBins, cwd), + ); + } +} + +/** + * Modules the agent-server imports at startup (`--import-modules`). They are + * resolved from `tools/`, which `buildAgentServerEnv` exposes through + * OH_EXTRA_PYTHON_PATH. Importing `canvas_ui_tool` eagerly registers the SDK's + * builtin FinishTool so automation presets (openhands-automation >= 1.9.0) can + * resolve it on the remote conversations they dispatch — see the note at the + * bottom of tools/canvas_ui_tool.py. + */ +export const AGENT_SERVER_IMPORT_MODULES = "canvas_ui_tool"; + +/** + * Build the uvx command and arguments for running agent-server. + * + * Environment variables (highest precedence first): + * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout. + * Runs the local checkout via uvx with editable installs of the workspace + * packages (openhands-sdk, openhands-tools, openhands-workspace) so source + * edits are picked up without a manual reinstall. The agent-server itself + * is rebuilt from local source on each invocation (--reinstall). + * - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name + * - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.46.0") + * + * If none are set, defaults to the released version specified by + * DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a + * git branch or commit instead. + * + * @param {Record} env + * @returns {{ command: string, args: string[], source: string }} + */ +export function buildAgentServerCommand(env = process.env) { + const localPath = env.OH_AGENT_SERVER_LOCAL_PATH; + const gitRef = env.OH_AGENT_SERVER_GIT_REF; + const version = env.OH_AGENT_SERVER_VERSION; + + const uvxArgs = []; + let source = ""; + + if (localPath) { + if (!path.isAbsolute(localPath)) { + throw new Error( + `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, + ); + } + uvxArgs.push( + "--reinstall", + "--from", + path.join(localPath, "openhands-agent-server"), + "--with-editable", + path.join(localPath, "openhands-sdk"), + "--with-editable", + path.join(localPath, "openhands-tools"), + "--with-editable", + path.join(localPath, "openhands-workspace"), + "--with", + AGENT_SERVER_POSTHOG_CONSTRAINT, + "agent-server", + ); + source = `local (${localPath})`; + } else if (gitRef) { + // Use git ref with subdirectory syntax for uv workspace monorepo. + // The software-agent-sdk repo has packages in subdirectories: + // openhands-agent-server/, openhands-sdk/, openhands-tools/, openhands-workspace/ + // All four must come from the same ref so inter-package APIs stay in sync. + // + // --reinstall is required because the git branch may carry the same version + // string as the current PyPI release (e.g. both "1.26.0"). Without it, uv + // silently reuses the cached PyPI wheels and the git ref is never actually + // used, even though it was explicitly requested. + const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`; + uvxArgs.push( + "--reinstall", + "--from", + `${baseGitUrl}#subdirectory=openhands-agent-server`, + "--with", + `${baseGitUrl}#subdirectory=openhands-sdk`, + "--with", + `${baseGitUrl}#subdirectory=openhands-tools`, + "--with", + `${baseGitUrl}#subdirectory=openhands-workspace`, + "--with", + AGENT_SERVER_POSTHOG_CONSTRAINT, + "agent-server", + ); + source = `git (${gitRef})`; + } else if (version) { + // Use specific PyPI version: uvx --from openhands-agent-server==version agent-server + // The package name differs from the executable name, so we need --from syntax + // Pin all SDK packages to the same version for consistency + uvxArgs.push( + "--from", + `${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`, + "--with", + `openhands-sdk==${version}`, + "--with", + `openhands-tools==${version}`, + "--with", + `openhands-workspace==${version}`, + ); + if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { + uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); + } + uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT); + uvxArgs.push("agent-server"); + source = `PyPI (${version})`; + } else { + // Default to released PyPI version + // Pin all SDK packages to the same version for consistency + uvxArgs.push( + "--from", + `${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`, + "--with", + `openhands-sdk==${DEFAULT_AGENT_SERVER_VERSION}`, + "--with", + `openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`, + "--with", + `openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`, + ); + if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { + uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); + } + uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT); + uvxArgs.push("agent-server"); + source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`; + } + + // Everything after the executable name is an agent-server CLI argument. + // Import the registration module before any conversation is created. + uvxArgs.push("--import-modules", AGENT_SERVER_IMPORT_MODULES); + + return { + command: "uvx", + args: uvxArgs, + source, + }; +} + +function parsePort(value, fallback) { + if (value == null || value === "") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid port: ${value}`); + } + + return parsed; +} + +/** + * Build safe dev configuration (synchronous version). + * + * Uses the port values from environment variables or defaults WITHOUT checking + * port availability. Use this when: + * - You need synchronous config (e.g., for test setup, config inspection) + * - Ports are already known to be available (e.g., specified via env vars) + * - You're building config objects for downstream use, not starting services + * + * For scripts that actually start services (dev-safe.mjs main, dev-with-automation.mjs), + * use {@link buildSafeDevConfigAsync} instead to handle port conflicts gracefully. + * + * @param {string} cwd - Current working directory + * @param {Record} env - Environment variables + * @returns {SafeDevConfig} Configuration object + */ +export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) { + const backendPort = parsePort( + env.OH_CANVAS_SAFE_BACKEND_PORT, + DEFAULT_BACKEND_PORT, + ); + const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1); + + return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env); +} + +/** + * Build safe dev configuration with dynamic port allocation. + * + * Tries preferred ports first; if busy, finds available alternatives. + * This is the recommended entry point for scripts that start services. + * + * @param {string} cwd - Current working directory + * @param {Record} env - Environment variables + * @returns {Promise} Configuration object with allocated ports + */ +export async function buildSafeDevConfigAsync( + cwd = process.cwd(), + env = process.env, +) { + // Get preferred ports from env or defaults + const preferredBackendPort = parsePort( + env.OH_CANVAS_SAFE_BACKEND_PORT, + DEFAULT_BACKEND_PORT, + ); + const preferredVscodePort = parsePort( + env.OH_CANVAS_SAFE_VSCODE_PORT, + preferredBackendPort + 1, + ); + + // Fail fast if any required port is already in use. + await assertPortsFree([ + { name: "agent-server", port: preferredBackendPort }, + { name: "vscode", port: preferredVscodePort }, + ]); + + return buildConfigFromPorts( + { backendPort: preferredBackendPort, vscodePort: preferredVscodePort }, + cwd, + env, + ); +} + +/** + * @typedef {object} SafeDevConfig + * @property {string} cwd + * @property {number} backendPort + * @property {number} vscodePort + * @property {string} vscodeBasePath + * @property {string} stateDir + * @property {string} tmuxTmpDir + * @property {string} conversationsPath + * @property {string} workspacesPath + * @property {string} bashEventsDir + * @property {string} backendBaseUrl + * @property {string} backendHost + * @property {string} workingDir + * @property {string} secretKey + * @property {string} sessionApiKey + * @property {string} canvasToolsDir + */ + +/** + * Internal helper to build config from already-resolved ports. + * @param {{backendPort: number, vscodePort: number}} ports + * @param {string} cwd + * @param {Record} env + * @returns {SafeDevConfig} + */ +function buildConfigFromPorts(ports, cwd, env) { + const { backendPort, vscodePort } = ports; + const stateDir = path.resolve( + cwd, + env.OH_CANVAS_SAFE_STATE_DIR || + path.join(homedir(), ".openhands", "agent-canvas"), + ); + const conversationsPath = path.join(stateDir, "dev_conversations"); + const workspacesPath = path.join(stateDir, "workspaces"); + // Use provided secret key, or read/generate one persisted to + // ~/.openhands/agent-canvas/secret-key.txt. Persisting ensures dev mode + // and Docker mode share the same encryption key when they mount the same + // ~/.openhands directory (docker/entrypoint.sh reads/writes the same file). + const secretKeyPath = env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH; + const secretKey = + env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret"); + // Use the user-provided LOCAL_BACKEND_API_KEY or fall back to a key + // persisted to ~/.openhands/agent-canvas/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. + // + // LOCAL_BACKEND_API_KEY is the single user-facing env var for the API key. + // OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests). + const persistedKeyPath = env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH; + const sessionApiKey = + env.LOCAL_BACKEND_API_KEY || + getOrCreatePersistedApiKeyFile(persistedKeyPath); + + // Host directory containing the legacy canvas_ui Python module. Persisted + // conversations created before the client_tools migration still reference + // its module qualname, so the agent-server can import it when resuming them. + const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url)); + + return { + cwd, + backendPort, + vscodePort, + vscodeBasePath: VSCODE_BASE_PATH, + stateDir, + // tmux socket directory. Defaults to /tmux (under + // ~/.openhands/agent-canvas), matching where the rest of dev state lives + // and persisting across restarts. + // + // Do NOT use os.tmpdir() here: on macOS it resolves to the per-user + // $TMPDIR (/var/folders/.../T), which the OS periodically reaps + // (com.apple.bsd.dirhelper deletes entries untouched for a few days). + // Reaping deletes the live tmux socket while the server process keeps + // running, orphaning it — every later new-window then fails with + // "error connecting to .../openhands (No such file or directory)". + // + // The only hosts where /tmux can't hold the socket are those + // whose $HOME is a network/overlay mount without Unix-domain-socket + // support (some devcontainers, NFS/CIFS homes). Those rare cases can point + // tmux at a local, socket-capable path with the standard TMUX_TMPDIR env + // var (e.g. TMUX_TMPDIR=/tmp), which we honor and pass through below. + tmuxTmpDir: env.TMUX_TMPDIR || path.join(stateDir, "tmux"), + conversationsPath, + workspacesPath, + bashEventsDir: path.join(stateDir, "bash_events"), + backendBaseUrl: `http://127.0.0.1:${backendPort}`, + backendHost: `127.0.0.1:${backendPort}`, + workingDir: env.VITE_WORKING_DIR || workspacesPath, + secretKey, + sessionApiKey, + canvasToolsDir, + }; +} + +/** + * Telemetry-related env vars for the agent-server process. + * + * Split out from `buildAgentServerEnv` so callers that assemble their own + * agent-server environment can reuse the same mapping. + * + * @param {Record} [env] - Source environment. + * @returns {Record} Telemetry env vars for agent-server + */ +export function buildAgentServerTelemetryEnv(env = process.env) { + const telemetryDisabled = + env.VITE_DO_NOT_TRACK === "1" || env.DO_NOT_TRACK === "1"; + const result = {}; + + for (const key of [ + "OH_TELEMETRY_EXPORTER", + "OH_TELEMETRY_POSTHOG_API_KEY", + "OH_TELEMETRY_POSTHOG_HOST", + "OH_TELEMETRY_HTTP_ENDPOINT", + "OH_TELEMETRY_HTTP_TOKEN", + "OH_TELEMETRY_CONSENT", + "OH_TELEMETRY_CONSENT_MODE", + "OH_TELEMETRY_SALT", + ]) { + if (env[key]) result[key] = env[key]; + } + + if (telemetryDisabled) { + result.DO_NOT_TRACK = "1"; + } + + const apiKey = + env.OH_TELEMETRY_POSTHOG_API_KEY || + env.VITE_POSTHOG_API_KEY || + (telemetryDisabled ? "" : DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY); + const exporter = env.OH_TELEMETRY_EXPORTER || (apiKey ? "posthog" : ""); + + if (exporter) { + result.OH_TELEMETRY_EXPORTER = exporter; + } + + if (exporter === "posthog" && apiKey) { + result.OH_TELEMETRY_POSTHOG_API_KEY = apiKey; + result.OH_TELEMETRY_POSTHOG_HOST = + env.OH_TELEMETRY_POSTHOG_HOST || + env.VITE_POSTHOG_HOST || + DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST; + } + + return result; +} + +/** + * Build the environment variables object for spawning the agent-server process. + * + * This is exported so downstream consumers (e.g., automation service) can use + * the same env vars without duplicating the mapping logic. + * + * `vscodeBasePath` is an explicit opt-in rather than a field read off `config`, + * and that is deliberate. Setting it changes the URL `/api/vscode/url` + * advertises: agent-server appends the prefix to the browser origin the + * frontend sends, so the editor is only reachable if the same origin also + * routes that prefix to the editor port. A launcher that sets it without + * registering the route advertises `/vscode/…`, which serves the + * canvas SPA shell instead of the editor. + * + * Requiring the caller to name it makes the pairing greppable: every call site + * that passes `vscodeBasePath` must also register a matching route, and + * `__tests__/scripts/vscode-base-path-opt-in.test.ts` asserts that no launcher + * opts in without one. + * + * @param {ReturnType} config - Config from buildSafeDevConfig + * @param {{vscodeBasePath?: string | null, env?: Record}} [options] + * @param {string | null} [options.vscodeBasePath] - Opt into prefix-mode by + * passing the path prefix the caller also routes to `config.vscodePort`. + * @param {Record} [options.env] - Source + * environment for the telemetry mapping (defaults to `process.env`). + * @returns {Record} Environment variables for agent-server + */ +export function buildAgentServerEnv(config, options = {}) { + const { vscodeBasePath = null, env = process.env } = options; + return { + ...buildAgentServerTelemetryEnv(env), + // Force Python to use UTF-8 for all file I/O and streams. + // + // On Windows, Python defaults to the system ANSI codepage (e.g. cp1252). + // The agent-server writes conversation metadata JSON that can contain + // emoji (e.g. ✅ U+2705) which cp1252 cannot encode, producing: + // UnicodeEncodeError: 'charmap' codec can't encode character '\u2705' + // Setting PYTHONUTF8=1 enables Python's UTF-8 mode (PEP 540) for the + // entire agent-server process, matching the behaviour on Linux/macOS + // where the locale is already UTF-8. + // This is a no-op on Linux/macOS where the locale is already UTF-8. + PYTHONUTF8: "1", + TMUX_TMPDIR: config.tmuxTmpDir, + // Parent of stateDir (= ~/.openhands) so settings/secrets match Docker. + OH_PERSISTENCE_DIR: path.dirname(config.stateDir), + OH_CONVERSATIONS_PATH: config.conversationsPath, + OH_BASH_EVENTS_DIR: config.bashEventsDir, + OH_VSCODE_PORT: String(config.vscodePort), + // Serve the editor under a path prefix on the canvas origin rather than on + // its own published port. agent-server passes this to openvscode-server as + // --server-base-path and includes it in the URL from /api/vscode/url, which + // matches the ingress route the caller registers for the same prefix. + // + // Omitted unless the caller opts in — see the note on this function. + ...(vscodeBasePath ? { OH_VSCODE_BASE_PATH: vscodeBasePath } : {}), + OH_SECRET_KEY: config.secretKey, + // Use OH_SESSION_API_KEYS_0 for agent-server V1 config format + OH_SESSION_API_KEYS_0: config.sessionApiKey, + // 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, + // Let the agent-server resolve canvas_ui_tool when old persisted metadata + // requests that compatibility module during startup. + OH_EXTRA_PYTHON_PATH: config.canvasToolsDir, + }; +} + +// Re-export so existing importers (dev-with-automation.mjs, tests) keep +// resolving `buildRuntimeServicesInfo` from this module. The implementation +// now lives in ./runtime-services-info.mjs (imported at the top of this file). +export { buildRuntimeServicesInfo }; + +export function buildNpmScriptCommand( + scriptName, + platform = process.platform, + env = process.env, + nodeExecPath = process.execPath, +) { + // On Windows, always use cmd.exe regardless of whether npm_execpath is set. + // npm_execpath points to a path like + // "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" which contains + // spaces. When that path is passed as an argument with shell:true in + // spawnService, cmd.exe splits on the space and tries to run "C:\Program" + // as a command, producing "not recognized as an internal or external command". + // Using "npm" via cmd.exe avoids the problem entirely. + if (platform === "win32") { + return { + command: env.ComSpec || "cmd.exe", + args: ["/d", "/s", "/c", "npm", "run", scriptName], + }; + } + + if (env.npm_execpath) { + return { + command: env.npm_node_execpath || nodeExecPath, + args: [env.npm_execpath, "run", scriptName], + }; + } + + return { + command: "npm", + args: ["run", scriptName], + }; +} + +export function validateLocalAgentServerPath(localPath) { + if (!path.isAbsolute(localPath)) { + throw new Error( + `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, + ); + } + if (!existsSync(localPath)) { + throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`); + } + for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) { + const subdirPath = path.join(localPath, subdir); + if (!existsSync(subdirPath)) { + throw new Error( + `OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`, + ); + } + } +} + +async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // Keep polling until timeout. + } + + await delay(500); + } + + throw new Error(`Timed out waiting for agent-server at ${url}`); +} + +function spawnProcess(command, args, options = {}) { + const child = spawn( + command, + args, + getProcessTreeSpawnOptions({ + stdio: "inherit", + ...options, + }), + ); + + child.once("error", (error) => { + if (isEnoentError(error) && command === "uvx") { + const msg = formatMissingUvxGuidance(options?.cwd); + console.error(msg); + fileLog("error", stripAnsi(msg)); + } else if (isEnoentError(error)) { + const msg = `Failed to start ${command}. Make sure it is installed and on your PATH.`; + console.error(msg); + fileLog("error", msg); + } else { + console.error(`Failed to start ${command}:`, error); + fileLog("error", `Failed to start ${command}: ${error.message}`); + } + }); + + return child; +} + +async function main() { + console.log("Starting isolated agent-server + frontend dev stack..."); + fileLog("info", "Starting isolated agent-server + frontend dev stack..."); + validateFrontendDependencies(); + console.log("Frontend dependencies found."); + fileLog("info", "Frontend dependencies found."); + console.log("Allocating ports..."); + fileLog("info", "Allocating ports..."); + + // Use async config builder with dynamic port allocation + const config = await buildSafeDevConfigAsync(); + + if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { + validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); + } + + for (const dir of [ + config.stateDir, + config.tmuxTmpDir, + config.conversationsPath, + config.workspacesPath, + config.bashEventsDir, + ]) { + mkdirSync(dir, { recursive: true }); + } + + const agentServerCmd = buildAgentServerCommand(); + + const secretKeySource = process.env.OH_SECRET_KEY + ? "custom (from OH_SECRET_KEY)" + : `persisted (${process.env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH})`; + + const sessionKeySource = process.env.LOCAL_BACKEND_API_KEY + ? "custom (from LOCAL_BACKEND_API_KEY)" + : `persisted (${ + process.env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH + })`; + + console.log(`- agent-server: ${agentServerCmd.source}`); + console.log(`- backend: ${config.backendBaseUrl}`); + console.log(`- vscode port: ${config.vscodePort}`); + 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(""); + fileLog( + "info", + [ + "Agent-server stack config:", + ` agent-server: ${agentServerCmd.source}`, + ` backend: ${config.backendBaseUrl}`, + ` working dir: ${config.workingDir}`, + ` state dir: ${config.stateDir}`, + ].join("\n"), + ); + + const backend = spawnProcess( + agentServerCmd.command, + [ + ...agentServerCmd.args, + "--host", + "127.0.0.1", + "--port", + String(config.backendPort), + ], + { + cwd: config.cwd, + env: { + ...process.env, + // Opt into prefix-mode: the Vite dev server proxies the same prefix to + // `config.vscodePort` (see VITE_VSCODE_TARGET below), so the advertised + // URL resolves on the frontend origin the browser is actually on. + ...buildAgentServerEnv(config, { + vscodeBasePath: config.vscodeBasePath, + }), + }, + }, + ); + + let shuttingDown = false; + let frontend = null; + + const shutdown = (signal = "SIGTERM") => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + if (frontend) { + signalProcessTree(frontend, signal); + } + signalProcessTree(backend, signal); + + setTimeout(() => { + if (frontend && isProcessRunning(frontend)) { + signalProcessTree(frontend, "SIGKILL"); + } + if (isProcessRunning(backend)) { + signalProcessTree(backend, "SIGKILL"); + } + process.exit(process.exitCode ?? 0); + }, 3000); + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + // Services are spawned detached, so a SIGHUP that kills this launcher (terminal + // or multiplexer death) would otherwise leave the whole tree running. Forward + // SIGTERM rather than SIGHUP: uvicorn only handles SIGINT/SIGTERM, so a + // forwarded SIGHUP would terminate the agent-server by default action instead + // of shutting it down gracefully. + process.on("SIGHUP", () => shutdown("SIGTERM")); + + const backendErrored = new Promise((_, reject) => { + backend.once("error", (error) => reject(error)); + }); + const backendExited = new Promise((_, reject) => { + backend.once("exit", (code, signal) => { + if (!shuttingDown) { + reject( + new Error( + `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`, + ), + ); + } + }); + }); + + try { + await Promise.race([ + waitForServer(`${config.backendBaseUrl}/server_info`), + backendErrored, + backendExited, + ]); + } catch (error) { + shutdown(); + throw error; + } + + const frontendCommand = buildNpmScriptCommand("dev:frontend"); + frontend = spawnProcess(frontendCommand.command, frontendCommand.args, { + cwd: config.cwd, + env: { + ...process.env, + 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, + // This mode has no static server or ingress in front of Vite, so Vite's + // own proxy is the only thing that can serve the editor prefix on the + // frontend origin. The editor is a separate process on a port of its + // own, so it needs its own proxy target rather than VITE_BACKEND_HOST. + VITE_VSCODE_BASE_PATH: config.vscodeBasePath, + VITE_VSCODE_TARGET: `http://127.0.0.1:${config.vscodePort}`, + // dev:minimal deliberately does NOT supply runtime-services info (the + // frontend here talks straight to the agent-server over + // VITE_BACKEND_BASE_URL — there is no ingress or static-server in front + // of it to append `runtime_services` to `/server_info`, and the + // frontend's own VITE_RUNTIME_SERVICES_INFO env var is no longer read). + // It is a bare agent-server + Vite stack with no companion services to + // advertise, so `fetchBackendRuntimeServicesInfo()` correctly returns + // null and conversations simply omit the block. + // Stacks with automation/ingress/frontend services should use + // `npm run dev` / `dev:static`, which pass runtime-services info through + // ingress/static-server instead. + }, + }); + + frontend.once("exit", (code) => { + shutdown(); + process.exitCode = code ?? 0; + }); + + backend.once("exit", (code) => { + if (!shuttingDown) { + const msg = `agent-server exited unexpectedly with code ${code ?? 0}`; + console.error(msg); + fileLog("error", msg); + shutdown(); + process.exitCode = code ?? 1; + } + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Conversation lease cleanup +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Returns true if `host:port` accepts a TCP connection within `timeoutMs`. + * Used to detect a live agent-server we shouldn't disturb. + */ +export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) { + return new Promise((resolve) => { + const socket = new net.Socket(); + let settled = false; + const finish = (busy) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(busy); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + socket.connect(port, host); + }); +} + +/** + * Remove stale `owner_lease.json` files under `conversationsDir` so a + * freshly spawned agent-server can claim ownership and re-load every + * existing conversation. + * + * Why this is needed: each conversation directory carries an + * `owner_lease.json` that locks it to a single agent-server's + * `owner_instance_id` for a 45 s TTL refreshed by heartbeat. On + * graceful shutdown the agent-server unlinks its leases; on a hard + * kill (or a fast restart, well under 45 s) the leases linger. A new + * agent-server with a fresh `owner_instance_id` will then raise + * `ConversationLeaseHeldError` for each conversation at startup load + * and skip it entirely — `/api/conversations/search` returns `[]` + * even though the meta files are right there on disk. + * + * The caller MUST verify (e.g. with `isPortBusy`) that no agent-server + * is currently bound to the backend port before calling this — there + * is no other reliable way to tell a stale lease from an actively + * renewed one. + * + * Returns the number of lease files unlinked. + */ +export function releaseStaleConversationLeases(conversationsDir) { + if (!existsSync(conversationsDir)) return 0; + + let removed = 0; + for (const name of readdirSync(conversationsDir)) { + const convDir = path.join(conversationsDir, name); + let isDir = false; + try { + isDir = statSync(convDir).isDirectory(); + } catch { + continue; + } + if (!isDir) continue; + + const leasePath = path.join(convDir, "owner_lease.json"); + if (!existsSync(leasePath)) continue; + try { + unlinkSync(leasePath); + removed += 1; + } catch { + // Best-effort: the new agent-server will simply skip this + // conversation as before. Don't fail the whole start. + } + } + return removed; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + console.error(msg); + fileLog("error", `Fatal error: ${msg}`); + if (error instanceof Error && error.stack) { + fileLog("error", error.stack); + } + process.exit(1); + }); +} diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs new file mode 100644 index 0000000000000000000000000000000000000000..80f5728a0ea82921f837e50b4755f46418944bb2 --- /dev/null +++ b/scripts/dev-static.mjs @@ -0,0 +1,665 @@ +/** + * Static-frontend Development Stack + * + * Same as the default automation stack but serves a production build of the + * frontend via `scripts/static-server.mjs` instead of the Vite dev server. + * Designed for slow / flaky network situations (e.g. plane wifi) + * where Vite's ~1000 individual module requests per page load are the + * bottleneck. The static build collapses the frontend into ~50 hashed + * chunks that all 304 cleanly on reload. + * + * Architecture (identical to dev-with-automation, only the frontend differs): + * ┌──────────────────────────────────────────────────────────────────────────┐ + * │ http://localhost:8000 (Ingress Proxy) │ + * │ /api/automation/* → Automation Backend │ + * │ /api/*, /sockets → Agent Server │ + * │ /* → Static Frontend │ + * └──────────────────────────────────────────────────────────────────────────┘ + * │ │ │ + * ▼ ▼ ▼ + * ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ + * │ sirv-cli │ │ Agent Server │ │ Automation │ + * │ build/ │ │ (uvx) :18000 │ │ Backend (uvx) │ + * │ :3001 │ │ │ │ :18001 │ + * └─────────────┘ └───────────────┘ └──────────────────┘ + * + * Usage: + * npm run dev:static + * npm run dev:static -- --port 12000 + * npm run dev:static -- --skip-build # reuse an existing build/ + * npm run dev:static -- --automation-ref feat/my-branch + * + * Environment variables (all optional, same as dev): + * - PORT: Ingress port (default: 8000) + * - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main) + * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server + * - OH_SECRET_KEY: Session secret key + */ + +import { spawn, spawnSync } from "node:child_process"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; +import process from "node:process"; + +import { buildFrontend } from "./static-build.mjs"; +import { + buildAgentServerCommand, + buildSafeDevConfig, + buildAgentServerEnv, + formatMissingUvxGuidance, + isPortBusy, + releaseStaleConversationLeases, +} from "./dev-safe.mjs"; +import { + getProcessTreeSpawnOptions, + isProcessRunning, + resolveWindowsCommand, + signalProcessTree, +} from "./dev-process-utils.mjs"; +import { + buildAgentServerAutomationEnv, + buildAutomationCommand, + buildAutomationTelemetryEnv, + buildAutomationRuntimeServicesInfo, + buildConfig, + buildRouteArgs, + getAgentServerBaseUrl, + getLocalServiceRoutes, + getNoReferrerPrefixArgs, + getVSCodeAdvertiseArgs, +} from "./dev-with-automation.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, ".."); + +// ═══════════════════════════════════════════════════════════════════════════ +// Terminal Styling +// ═══════════════════════════════════════════════════════════════════════════ + +const c = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function logService(name, message, color = c.reset) { + const ts = new Date().toISOString().split("T")[1].split(".")[0]; + console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`); +} + +function logStep(step, message) { + console.log(`${c.cyan}[${step}]${c.reset} ${message}`); +} + +function logSuccess(message) { + console.log(`${c.green}✓${c.reset} ${message}`); +} + +function logError(message) { + console.error(`${c.red}✗${c.reset} ${message}`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CLI parsing +// ═══════════════════════════════════════════════════════════════════════════ + +export function parseArgs(argv = process.argv.slice(2)) { + const config = { + port: null, + automationGitRef: null, + automationRepo: null, + skipBuild: false, + verbose: false, + }; + + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case "-p": + case "--port": + config.port = parseInt(argv[++i], 10); + break; + case "--automation-ref": + config.automationGitRef = argv[++i]; + break; + case "--automation-repo": + config.automationRepo = argv[++i]; + break; + case "--skip-build": + config.skipBuild = true; + break; + case "-v": + case "--verbose": + config.verbose = true; + break; + case "-h": + case "--help": + showHelp(); + process.exit(0); + } + } + + return config; +} + +function showHelp() { + console.log(` +Agent Canvas Static-frontend Development Stack + +Runs the automation stack, but serves a production build of the +frontend via scripts/static-server.mjs. Use this when a remote or flaky network +makes Vite's per-module requests painful (e.g. ngrok or plane wifi). + +USAGE: + npm run dev:static [-- options] + +OPTIONS: + -p, --port Ingress port (default: 8000) + --automation-ref Git ref for automation backend (default: main) + --automation-repo Git repo URL for automation + --skip-build Reuse existing build/ directory (faster restart) + -v, --verbose Show detailed output + -h, --help Show this help + +ENVIRONMENT VARIABLES: + PORT Alternative to --port + OH_AUTOMATION_GIT_REF Alternative to --automation-ref + OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK + OH_SECRET_KEY Secret key for sessions + +ACCESS POINTS: + Main UI: http://localhost:PORT/ + API Docs: http://localhost:PORT/api/automation/docs + +NOTES: + • The build is produced once at startup. Edit the source and rerun this + command (or rebuild with \`npm run build:app\`) to pick up changes. + • The static server sends ETag headers, so reloads return 304s instead of + refetching content — much friendlier on slow links. +`); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Prerequisites & Setup +// ═══════════════════════════════════════════════════════════════════════════ + +function commandExists(cmd) { + const result = + process.platform === "win32" + ? spawnSync("where.exe", [cmd], { stdio: "pipe" }) + : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" }); + + return result.status === 0; +} + +function checkPrerequisites() { + logStep("1/3", "Checking prerequisites..."); + + if (!commandExists("uvx")) { + console.error(formatMissingUvxGuidance(projectRoot)); + process.exit(1); + } + logSuccess("uvx found"); + + if (!commandExists("npm")) { + logError("npm is required but not found"); + process.exit(1); + } + logSuccess("npm found"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Process Management +// ═══════════════════════════════════════════════════════════════════════════ + +const processes = new Map(); +let shuttingDown = false; + +function spawnService(name, command, args, options = {}) { + const proc = spawn( + resolveWindowsCommand(command), + args, + getProcessTreeSpawnOptions({ + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, + cwd: options.cwd, + }), + ); + + const color = options.color || c.reset; + + proc.stdout.on("data", (data) => { + data + .toString() + .split("\n") + .filter(Boolean) + .forEach((line) => logService(name, line.trim(), color)); + }); + + proc.stderr.on("data", (data) => { + data + .toString() + .split("\n") + .filter(Boolean) + .forEach((line) => logService(name, line.trim(), c.yellow)); + }); + + proc.on("error", (error) => { + logError(`${name} failed to start: ${error.message}`); + }); + + proc.on("exit", (code) => { + if (code !== 0 && code !== null && !shuttingDown) { + logService(name, `Exited with code ${code}`, c.red); + } + processes.delete(name); + }); + + processes.set(name, proc); + return proc; +} + +async function waitForService(name, url, timeoutMs = 30000) { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url); + if (res.ok) { + logService(name, `Ready at ${url}`, c.green); + return true; + } + } catch { + // Keep trying + } + await delay(500); + } + + logService(name, `Timeout waiting for ${url}`, c.red); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Service Starters (agent-server + automation are byte-for-byte the same as +// dev-with-automation; the only difference is the frontend service.) +// ═══════════════════════════════════════════════════════════════════════════ + +// The static server and the ingress proxy front the same local backends, so +// they share one route table — dev-with-automation's, rather than a second +// copy here. The copy this replaces claimed to stay identical to that table +// but nothing enforced it, and it had already drifted: the editor prefix was +// missing, so `/vscode` fell through to the SPA fallback and answered editor +// requests with the canvas shell. +// +// This mode always launches both local backends (it never runs frontend-only), +// so it asks for their routes unconditionally. Every target is IPv4 loopback: +// the backends bind to `0.0.0.0`, which only accepts IPv4, but localhost can +// resolve to ::1 first (notably on Windows). +function buildLocalServiceRouteArgs(config) { + return buildRouteArgs( + getLocalServiceRoutes({ + ...config, + launchAgentServer: true, + launchAutomation: true, + }), + ); +} + +function startAgentServer(config) { + logService( + "agent-server", + `Starting on port ${config.agentServerPort}...`, + c.blue, + ); + + const agentServerCmd = buildAgentServerCommand(process.env); + logService("agent-server", `Using ${agentServerCmd.source}`, c.dim); + + const safeConfig = buildSafeDevConfig(config.canvasPath, { + ...process.env, + OH_CANVAS_SAFE_STATE_DIR: config.stateDir, + OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(), + OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(), + }); + + const agentServerEnv = { + // Opt into prefix-mode: both the static server and the ingress below build + // their route tables from `getLocalServiceRoutes`, which registers this + // same prefix against `config.vscodePort`. + ...buildAgentServerEnv(safeConfig, { + vscodeBasePath: config.vscodeBasePath, + }), + ...buildAgentServerAutomationEnv(config), + }; + + spawnService( + "agent-server", + agentServerCmd.command, + [ + ...agentServerCmd.args, + "--host", + "0.0.0.0", + "--port", + String(config.agentServerPort), + ], + { + cwd: safeConfig.workspacesPath, + env: agentServerEnv, + color: c.blue, + }, + ); +} + +function buildAutomationBackendEnv(config, env = process.env) { + // Both backends share the same session API key value. + return { + AUTOMATION_AGENT_SERVER_URL: getAgentServerBaseUrl(config), + AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, + 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.sessionApiKey, + ...buildAutomationTelemetryEnv(env), + 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"), + OPENHANDS_SUPPRESS_BANNER: "1", + }; +} + +function startAutomationBackend(config) { + logService( + "automation", + `Starting on port ${config.autoBackendPort}...`, + c.green, + ); + + const automationCmd = buildAutomationCommand(process.env); + logService("automation", `Using ${automationCmd.source}`, c.dim); + + spawnService( + "automation", + automationCmd.command, + [ + ...automationCmd.args, + "--host", + "0.0.0.0", + "--port", + config.autoBackendPort.toString(), + ], + { + cwd: config.stateDir, + env: buildAutomationBackendEnv(config), + color: c.green, + }, + ); +} + +function startStaticServer(config) { + // Reuse `vitePort` as the upstream port name so the ingress route table + // below stays identical to dev-with-automation.mjs. + logService("static", `Starting on port ${config.vitePort}...`, c.magenta); + + // Mirror the proxy targets that vite.config.ts exposes in dev mode so that + // hitting :3001 directly behaves like Vite's dev server (e.g. /server_info + // is forwarded to the agent-server instead of falling back to the SPA + // shell). Without this, /server_info on :3001 returns index.html. + const staticServerScript = join(projectRoot, "scripts", "static-server.mjs"); + const runtimeServicesInfo = JSON.stringify( + buildAutomationRuntimeServicesInfo({ + ...config, + frontendKind: "static", + }), + ); + spawnService( + "static", + "node", + [ + staticServerScript, + "--dir", + join(config.canvasPath, "build"), + "--port", + String(config.vitePort), + ...(process.env.VITE_BASE_PATH + ? ["--base-path", process.env.VITE_BASE_PATH] + : []), + // Inject the API key so the pre-built frontend can authenticate + // to the agent-server without a baked-in VITE_SESSION_API_KEY. + ...(config.sessionApiKey + ? ["--session-api-key", config.sessionApiKey] + : []), + "--runtime-services-info", + runtimeServicesInfo, + ...buildLocalServiceRouteArgs(config), + // Only the static server injects into the document, so only it can tell + // the frontend this origin serves the editor. The ingress below routes + // the same prefix but proxies the HTML through untouched. + ...getVSCodeAdvertiseArgs(config), + ...getNoReferrerPrefixArgs(config), + ], + { + cwd: config.canvasPath, + color: c.magenta, + }, + ); +} + +function startIngress(config) { + logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); + + const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); + const runtimeServicesInfo = JSON.stringify( + buildAutomationRuntimeServicesInfo({ + ...config, + frontendKind: "static", + }), + ); + + spawnService( + "ingress", + "node", + [ + ingressScript, + "--port", + config.ingressPort.toString(), + "--runtime-services-info", + runtimeServicesInfo, + ...buildLocalServiceRouteArgs(config), + ...getNoReferrerPrefixArgs(config), + "--default", + `http://localhost:${config.vitePort}`, + ], + { + cwd: projectRoot, + color: c.yellow, + }, + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Shutdown / Banner +// ═══════════════════════════════════════════════════════════════════════════ + +function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + + console.log(""); + console.log(`${c.yellow}Shutting down...${c.reset}`); + + for (const [name, proc] of processes) { + logService(name, "Stopping...", c.dim); + signalProcessTree(proc, "SIGTERM"); + } + + setTimeout(() => { + for (const [name, proc] of processes) { + if (isProcessRunning(proc)) { + logService(name, "Force stopping...", c.dim); + signalProcessTree(proc, "SIGKILL"); + } + } + process.exit(0); + }, 3000); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +function printBanner(config) { + console.log(""); + console.log( + `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas Static-frontend Stack${c.reset} ${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd( + 75, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd( + 75, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`, + ); + console.log(""); + console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`); + console.log( + `${c.dim}Frontend served from: ${join(config.canvasPath, "build")}${c.reset}`, + ); + console.log( + `${c.dim}Edit sources, then re-run \`npm run dev:static\` to rebuild.${c.reset}`, + ); + console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`); + console.log(""); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════════════════ + +async function main() { + const args = parseArgs(); + const config = await buildConfig(args); + + console.log(""); + console.log( + `${c.cyan}${c.bold}Agent Canvas Static-frontend Development Stack${c.reset}`, + ); + console.log(""); + + // Setup phase (1/3) + checkPrerequisites(); + + // Ensure isolated state dirs (same as dev-with-automation). + const { mkdirSync } = await import("node:fs"); + for (const dir of [ + config.stateDir, + join(config.stateDir, "dev_conversations"), + join(config.stateDir, "workspaces"), + join(config.stateDir, "bash_events"), + join(config.stateDir, "storage"), + ]) { + mkdirSync(dir, { recursive: true }); + } + + // Build phase (2/3): block until the SPA is ready to serve. + buildFrontend(config, args); + + // Service phase (3/3) + logStep("3/3", "Starting services..."); + + // The agent-server skip-loads any conversation whose `owner_lease.json` + // is held by a different `owner_instance_id` and not yet expired (45 s + // TTL). If a previous agent-server (e.g. from `npm run dev`) was killed + // ungracefully — or we restart faster than the lease TTL — every + // conversation gets hidden until those stale leases age out, which + // looks like "the new agent-server doesn't inherit my conversations". + // Bail out if a live agent-server is already bound to our port (we'd + // collide anyway), otherwise unlink the stale leases so the new server + // can claim ownership immediately. + if (await isPortBusy(config.agentServerPort)) { + logError( + `Port ${config.agentServerPort} is already in use — another ` + + `agent-server is running. Stop it (e.g. quit \`npm run dev\`) ` + + `before running dev:static.`, + ); + process.exit(1); + } + const conversationsPath = join(config.stateDir, "dev_conversations"); + const cleared = releaseStaleConversationLeases(conversationsPath); + if (cleared > 0) { + logService( + "agent-server", + `Released ${cleared} stale conversation lease(s) so the new ` + + `agent-server can resume ownership.`, + c.dim, + ); + } + + startAgentServer(config); + await waitForService( + "agent-server", + `${getAgentServerBaseUrl(config)}/server_info`, + ); + + startAutomationBackend(config); + + startStaticServer(config); + + await delay(2000); + + startIngress(config); + + await delay(1000); + + printBanner(config); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Exports for testing +// ═══════════════════════════════════════════════════════════════════════════ + +export { + buildAutomationBackendEnv, + buildFrontend, + buildLocalServiceRouteArgs, + startStaticServer, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Main entry point (only when run directly, not when imported) +// ═══════════════════════════════════════════════════════════════════════════ + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + main().catch((err) => { + logError(`Fatal error: ${err.message}`); + if (err.stack) { + console.error(c.dim + err.stack + c.reset); + } + process.exit(1); + }); +} diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ff89676c2c147072e0fcd18f7dec20b1ac0535d7 --- /dev/null +++ b/scripts/dev-with-automation.mjs @@ -0,0 +1,1715 @@ +/** + * Development Stack with Automation Service + * + * Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation + * backend via uvx. No cloning required - runs directly from git reference. + * + * Uses a standalone ingress proxy to route traffic to multiple backends. + * + * Architecture: + * ┌──────────────────────────────────────────────────────────────────────────┐ + * │ http://localhost:8000 (Ingress Proxy) │ + * │ /api/automation/* → Automation Backend │ + * │ /api/*, /sockets → Agent Server │ + * │ /* → Vite Dev Server │ + * └──────────────────────────────────────────────────────────────────────────┘ + * │ │ │ + * ▼ ▼ ▼ + * ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ + * │ Vite │ │ Agent Server │ │ Automation │ + * │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │ + * │ │ │ │ │ :18001 │ + * └─────────────┘ └───────────────┘ └──────────────────┘ + * + * Usage: + * node scripts/dev-with-automation.mjs + * node scripts/dev-with-automation.mjs --automation-ref feat/my-branch + * node scripts/dev-with-automation.mjs --port 12000 + * + * Environment variables: + * - PORT: Ingress port (default: 8000) + * - OH_AUTOMATION_GIT_REF: Git ref for automation (overrides default version) + * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk + * checkout. Highest precedence for agent-server source selection: rebuilds + * the agent-server from local source and installs openhands-sdk, + * 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 + * Secrets: + * The session API key is automatically seeded into agent-server secrets + * as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. + * Both the agent-server and automation backend use the same key value + * and the same `X-Session-API-Key` header for authentication. + * AUTOMATION_KV_SECRET is derived from the session key if not set explicitly, + * enabling the KV store out of the box for local development. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { mkdirSync, existsSync, readFileSync } from "node:fs"; +import { join, resolve, dirname, isAbsolute } 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"; + +import { + assertPortsFree, + buildAgentServerCommand, + buildSafeDevConfig, + buildAgentServerEnv, + buildNpmScriptCommand, + buildRuntimeServicesInfo, + formatMissingUvxGuidance, + validateFrontendDependencies, + validateLocalAgentServerPath, +} from "./dev-safe.mjs"; +import { + createShutdownHookRegistry, + getProcessTreeSpawnOptions, + isProcessRunning, + resolveWindowsCommand, + signalProcessTree, +} from "./dev-process-utils.mjs"; +import { fileLog, stripAnsi } from "./logger.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, ".."); + +// ── Centralized config (single source of truth for versions, ports, etc.) ─── +const SHARED_DEFAULTS = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), +); + +const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation"; +const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation; +const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation; +const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer; +const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; +const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation; +const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey; +const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost; + +// ═══════════════════════════════════════════════════════════════════════════ +// Terminal Styling +// ═══════════════════════════════════════════════════════════════════════════ + +const c = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function logService(name, message, color = c.reset) { + const ts = new Date().toISOString().split("T")[1].split(".")[0]; + console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`); + fileLog("info", `[${name}] ${stripAnsi(message)}`); +} + +function logStep(step, message) { + console.log(`${c.cyan}[${step}]${c.reset} ${message}`); + fileLog("info", `[${step}] ${message}`); +} + +function logSuccess(message) { + console.log(`${c.green}✓${c.reset} ${message}`); + fileLog("info", `✓ ${message}`); +} + +function logError(message) { + console.error(`${c.red}✗${c.reset} ${message}`); + fileLog("error", `✗ ${stripAnsi(message)}`); +} + +/** + * Parse one JSON log line produced by the SDK's JsonFormatter and return a + * single-line human-readable string + an appropriate ANSI color. + * + * Returns null for non-JSON lines so callers can fall back to the raw text. + * + * @param {string} rawLine + * @returns {{ text: string; color: string } | null} + */ +function parseAgentServerLogLine(rawLine) { + try { + const obj = JSON.parse(rawLine); + if (!obj.levelname || obj.message === undefined) return null; + const level = obj.levelname.padEnd(8); + const location = + obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : ""; + const text = `${level} ${obj.message}${location}`; + const lvl = obj.levelname; + const color = + lvl === "DEBUG" + ? c.dim + : lvl === "WARNING" + ? c.yellow + : lvl === "ERROR" || lvl === "CRITICAL" + ? c.red + : c.blue; + return { text, color }; + } catch { + return null; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Configuration +// ═══════════════════════════════════════════════════════════════════════════ + +function parseArgs() { + const args = process.argv.slice(2); + const config = { + port: null, + automationGitRef: null, + automationRepo: null, + verbose: false, + static: false, + dynamic: false, + staticDir: null, + skipBuild: false, + public: false, + frontendOnly: false, + backendOnly: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "-p": + case "--port": + config.port = parseInt(args[++i], 10); + break; + case "--automation-ref": + config.automationGitRef = args[++i]; + break; + case "--automation-repo": + config.automationRepo = args[++i]; + break; + case "-v": + case "--verbose": + config.verbose = true; + break; + case "--static": + config.static = true; + break; + case "--dynamic": + config.dynamic = true; + break; + case "--static-dir": + config.staticDir = args[++i]; + break; + case "--skip-build": + config.skipBuild = true; + break; + case "--public": + config.public = true; + break; + case "--frontend-only": + config.frontendOnly = true; + break; + case "--backend-only": + config.backendOnly = true; + break; + case "-h": + case "--help": + showHelp(); + process.exit(0); + } + } + + return config; +} + +function showHelp() { + console.log(` +Agent Canvas + Automation Development Stack + +Runs agent-canvas with the automation backend (via uvx, no clone needed). +Uses a standalone ingress proxy to route traffic. + +USAGE: + node scripts/dev-with-automation.mjs [options] + +OPTIONS: + -p, --port Ingress port (default: 8000) + --automation-ref Git ref for automation (branch/tag/SHA) + --automation-repo Git repo URL (default: ${DEFAULT_AUTOMATION_REPO}) + --static Serve an existing production build instead of Vite + --static-dir Static build directory (default: build/) + --skip-build Reuse build/ when the launcher builds static assets + --dynamic Force Vite dev server when a wrapper defaults static + --frontend-only Start only the frontend behind ingress + --backend-only Start only agent-server + automation behind ingress + -v, --verbose Show detailed output + -h, --help Show this help + +ENVIRONMENT VARIABLES: + PORT Alternative to --port + OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version) + OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION}) + OH_AUTOMATION_LOCAL_PATH Absolute path to a local automation checkout (overridden only by --automation-git-ref) + OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence) + 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 + +SECRETS: + The session API key is automatically seeded into agent-server secrets + as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. + Both backends (agent-server and automation) share the same key value. + AUTOMATION_KV_SECRET defaults to the session key so the KV store works + out of the box; override with an explicit value for stronger isolation. + +ACCESS POINTS: + Main UI: http://localhost:PORT/ + API Docs: http://localhost:PORT/api/automation/docs +`); +} + +/** + * Fail fast on an unusable OH_AUTOMATION_LOCAL_PATH instead of letting + * `uv run --project ` exit on its own -- that leaves the rest of the + * stack up and the automations UI just reporting "backend unavailable", with + * nothing pointing at the env var. Mirrors validateLocalAgentServerPath. + */ +function validateLocalAutomationPath(localPath) { + if (!isAbsolute(localPath)) { + throw new Error( + `OH_AUTOMATION_LOCAL_PATH must be an absolute path, got: ${localPath}`, + ); + } + if (!existsSync(localPath)) { + throw new Error(`OH_AUTOMATION_LOCAL_PATH does not exist: ${localPath}`); + } + const projectFile = join(localPath, "pyproject.toml"); + if (!existsSync(projectFile)) { + throw new Error( + `OH_AUTOMATION_LOCAL_PATH is not a Python project (no pyproject.toml): ${projectFile}`, + ); + } +} + +/** + * Build the uvx command for running automation backend. + * + * Environment variables (highest precedence first): + * - OH_AUTOMATION_LOCAL_PATH: Absolute path to a local checkout + * - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name + * - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1") + * + * If none are set, defaults to the released version specified by + * DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a + * git branch or commit instead. + */ +function buildAutomationCommand(env = process.env) { + const localPath = env.OH_AUTOMATION_LOCAL_PATH; + const gitRef = env.OH_AUTOMATION_GIT_REF; + const version = env.OH_AUTOMATION_VERSION; + const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO; + + const uvxArgs = []; + let source = ""; + + if (localPath) { + // Run straight from a local checkout via `uv run --project`, so + // uncommitted working-tree changes are picked up. Outranks the other + // automation env vars, mirroring OH_AGENT_SERVER_LOCAL_PATH for the + // agent-server SDK; buildConfig drops it when --automation-git-ref asks + // for a specific ref. + return { + command: "uv", + args: [ + "run", + "--project", + localPath, + "uvicorn", + "openhands.automation.app:app", + ], + source: `local (${localPath})`, + }; + } + + if (gitRef) { + // Use git ref - refresh to ensure latest commit is fetched + const gitUrl = `git+${repoUrl}@${gitRef}`; + uvxArgs.push( + "--refresh", + "--from", + gitUrl, + "uvicorn", + "openhands.automation.app:app", + ); + source = `git (${gitRef})`; + } else if (version) { + // Use specific PyPI version + uvxArgs.push( + "--from", + `${DEFAULT_AUTOMATION_PACKAGE}==${version}`, + "uvicorn", + "openhands.automation.app:app", + ); + source = `PyPI (${version})`; + } else { + // Default to released PyPI version + uvxArgs.push( + "--from", + `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`, + "uvicorn", + "openhands.automation.app:app", + ); + source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`; + } + + return { + command: "uvx", + args: uvxArgs, + source, + }; +} + +async function buildConfig(args, env = process.env) { + // Apply args to env for buildAutomationCommand + if (args.automationGitRef) { + env.OH_AUTOMATION_GIT_REF = args.automationGitRef; + // An explicit flag outranks an ambient env var. Otherwise someone with + // OH_AUTOMATION_LOCAL_PATH exported in their shell profile would run their + // own working tree while believing they were reproducing against the ref + // they just passed. + if (env.OH_AUTOMATION_LOCAL_PATH) { + logStep( + "automation", + `--automation-git-ref ${args.automationGitRef} overrides OH_AUTOMATION_LOCAL_PATH (${env.OH_AUTOMATION_LOCAL_PATH})`, + ); + delete env.OH_AUTOMATION_LOCAL_PATH; + } + } + if (args.automationRepo) { + env.OH_AUTOMATION_REPO = args.automationRepo; + } + + const frontendOnly = Boolean(args.frontendOnly); + const backendOnly = Boolean(args.backendOnly); + if (frontendOnly && backendOnly) { + throw new Error( + "--frontend-only and --backend-only cannot be used together", + ); + } + + const launchFrontend = !backendOnly; + const launchAgentServer = !frontendOnly; + const launchAutomation = !frontendOnly; + const isPublic = args.public; + + if (isPublic && frontendOnly) { + throw new Error("--public cannot be used with --frontend-only"); + } + + // In public mode, LOCAL_BACKEND_API_KEY is required — without it the + // auth screen has nothing to validate against. + if (isPublic && !env.LOCAL_BACKEND_API_KEY) { + logError( + "PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" + + " Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public", + ); + process.exit(1); + } + + // Preferred ports (from env or defaults). + // OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT / + // OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect + // internal service ports without affecting the production default. + const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000; + const preferredBackendPort = + parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT; + const preferredAutomationPort = + parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT; + const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001; + + // Fail fast if any preferred port for a service in this mode is already in use. + const requiredPorts = [{ name: "ingress", port: preferredIngressPort }]; + if (launchAgentServer) { + requiredPorts.push({ name: "agent-server", port: preferredBackendPort }); + } + if (launchAutomation) { + requiredPorts.push({ name: "automation", port: preferredAutomationPort }); + } + if (launchFrontend) { + requiredPorts.push({ name: "frontend", port: preferredVitePort }); + } + + logStep("ports", "Checking ports..."); + await assertPortsFree(requiredPorts); + + const vscodePort = preferredBackendPort + 1000; + + // API key — shared by both agent-server and automation backend. + // Both validate it via the `X-Session-API-Key` header. + // LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's + // used directly; otherwise one is auto-generated and persisted. + const stateDir = + env.OH_CANVAS_SAFE_STATE_DIR || + join(homedir(), ".openhands", "agent-canvas"); + + const safeConfig = buildSafeDevConfig(projectRoot, { + ...env, + OH_CANVAS_SAFE_STATE_DIR: stateDir, + OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(), + OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(), + }); + const sessionApiKey = safeConfig.sessionApiKey; + + if (isPublic) { + logService( + "auth", + "PUBLIC MODE — key will NOT be injected into the frontend", + c.yellow, + ); + logService( + "auth", + "Users must paste the LOCAL_BACKEND_API_KEY in the browser", + c.dim, + ); + } + + return { + // Ingress port (main entry point) + ingressPort: preferredIngressPort, + + // Service ports (internal) + agentServerPort: preferredBackendPort, + autoBackendPort: preferredAutomationPort, + vitePort: preferredVitePort, + vscodePort, + // Prefix the editor is served under on the ingress origin. Carried on the + // config so the route table and the agent-server env are built from one + // value (see getLocalServiceRoutes / buildAgentServerEnv). + vscodeBasePath: safeConfig.vscodeBasePath, + + // Paths + canvasPath: projectRoot, + + // Data directories (same as dev-safe.mjs) + stateDir, + // Only bake the host-side workspace path when this launcher also starts + // the agent-server that can read it. In frontend-only mode the backend may + // be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the + // user explicitly supplied a backend-relative value. + viteWorkingDir: launchAgentServer + ? safeConfig.workingDir + : env.VITE_WORKING_DIR, + + // Auth — single key for both backends + sessionApiKey, + + // Public mode — the session key should NOT be baked into the frontend + isPublic, + + frontendOnly, + backendOnly, + launchFrontend, + launchAgentServer, + launchAutomation, + + verbose: args.verbose, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Prerequisites & Setup +// ═══════════════════════════════════════════════════════════════════════════ + +function commandExists(cmd) { + const result = + process.platform === "win32" + ? spawnSync("where.exe", [cmd], { stdio: "pipe" }) + : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" }); + + return result.status === 0; +} + +function checkPrerequisites({ + checkUvx = true, + checkNpm = true, + checkFrontendDependencies = true, +} = {}) { + logStep("1/2", "Checking prerequisites..."); + + if (checkUvx) { + if (!commandExists("uvx")) { + const uvxGuidance = formatMissingUvxGuidance(projectRoot); + console.error(uvxGuidance); + fileLog("error", stripAnsi(uvxGuidance)); + process.exit(1); + } + logSuccess("uvx found"); + } + + if (checkNpm) { + if (!commandExists("npm")) { + logError("npm is required but not found"); + process.exit(1); + } + logSuccess("npm found"); + } + + if (checkFrontendDependencies) { + try { + validateFrontendDependencies(projectRoot); + } catch (error) { + logError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + logSuccess("frontend dependencies found"); + } +} + +function ensureDirectories(config) { + const dirs = [ + config.stateDir, + // Both agent-server and automation use storage; create it unconditionally + // whenever either backend service runs (i.e. not frontend-only). + ...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []), + ]; + + if (config.launchAgentServer) { + dirs.push( + join(config.stateDir, "dev_conversations"), + join(config.stateDir, "workspaces"), + join(config.stateDir, "bash_events"), + ); + } + + if (config.launchAutomation) { + dirs.push( + // Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour. + dirname( + join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb), + ), + ); + } + + for (const dir of dirs) { + mkdirSync(dir, { recursive: true }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Process Management +// ═══════════════════════════════════════════════════════════════════════════ + +const processes = new Map(); +const shutdownHooks = createShutdownHookRegistry((err) => { + logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow); +}); + +// Optional external listener for every service log line. Set by `main()` from +// its `onServiceLog` option so embedded launchers (e.g. the Electron desktop +// app) can stream uvx download / install progress to their loading window +// without touching the terminal logging path. Receives `(name, line, level)` +// where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error". +let serviceLogListener = null; + +export function setServiceLogListener(listener) { + serviceLogListener = typeof listener === "function" ? listener : null; +} + +function emitServiceLog(name, line, level) { + if (!serviceLogListener) return; + try { + serviceLogListener(name, line, level); + } catch { + // Never let a listener bug crash the dev stack. + } +} + +function registerShutdownHook(hook) { + return shutdownHooks.add(hook); +} + +function spawnService(name, command, args, options = {}) { + const proc = spawn( + resolveWindowsCommand(command), + args, + getProcessTreeSpawnOptions({ + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, + cwd: options.cwd, + }), + ); + + const color = options.color || c.reset; + const parseLogLine = options.parseLogLine; + + proc.stdout.on("data", (data) => { + data + .toString() + .split("\n") + .filter(Boolean) + .forEach((line) => { + const trimmed = line.trim(); + const parsed = parseLogLine ? parseLogLine(trimmed) : null; + logService( + name, + parsed ? parsed.text : trimmed, + parsed ? parsed.color : color, + ); + emitServiceLog(name, trimmed, "stdout"); + }); + }); + + proc.stderr.on("data", (data) => { + data + .toString() + .split("\n") + .filter(Boolean) + .forEach((line) => { + const trimmed = line.trim(); + const parsed = parseLogLine ? parseLogLine(trimmed) : null; + logService( + name, + parsed ? parsed.text : trimmed, + parsed ? parsed.color : c.yellow, + ); + emitServiceLog(name, trimmed, "stderr"); + }); + }); + + proc.on("error", (error) => { + logError(`${name} failed to start: ${error.message}`); + emitServiceLog(name, `failed to start: ${error.message}`, "error"); + }); + + proc.on("exit", (code, _signal) => { + if (code !== 0 && code !== null && !shuttingDown) { + logService(name, `Exited with code ${code}`, c.red); + emitServiceLog(name, `exited with code ${code}`, "error"); + } + processes.delete(name); + }); + + processes.set(name, proc); + return proc; +} + +async function waitForService(name, url, timeoutMs = 30000) { + const start = Date.now(); + let lastError = null; + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (res.ok) { + logService(name, `Ready at ${url}`, c.green); + return true; + } + } catch (err) { + lastError = err; + // Keep trying + } + await delay(500); + } + + const elapsed = Math.round((Date.now() - start) / 1000); + logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red); + if (lastError) { + logService(name, `Last error: ${lastError.message}`, c.dim); + } + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Service Starters +// ═══════════════════════════════════════════════════════════════════════════ + +const AUTOMATION_ROUTE_PREFIX = "/api/automation"; +const AGENT_SERVER_ROUTE_PREFIXES = [ + "/api", + "/sockets", + "/server_info", + "/health", + "/ready", + "/alive", + "/docs", + "/redoc", + "/openapi.json", +]; + +// This launcher starts the agent-server with `--host 127.0.0.1`, but localhost +// can resolve to ::1 first (notably on Windows), so every request this process +// or the automation backend makes to it must address IPv4 explicitly. +function getAgentServerBaseUrl(config) { + return `http://127.0.0.1:${config.agentServerPort}`; +} + +function getLocalServiceRoutes(config) { + const routes = []; + + // These services bind to IPv4 loopback, but localhost can resolve to ::1. + if (config.launchAutomation) { + routes.push([ + AUTOMATION_ROUTE_PREFIX, + `http://127.0.0.1:${config.autoBackendPort}`, + ]); + } + + if (config.launchAgentServer) { + for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { + routes.push([prefix, getAgentServerBaseUrl(config)]); + } + + // The editor is a separate process on its own port, but it is reached + // through the same origin as the canvas so no second port has to be + // published. The prefix is deliberately preserved rather than stripped: + // agent-server launches openvscode-server with `--server-base-path`, so + // the editor generates its own HTTP and WebSocket URLs beneath the prefix + // and only answers there. `createRouter` matches the longest prefix and + // the proxy forwards the original path, so both are already handled. + if (config.vscodeBasePath) { + routes.push([ + config.vscodeBasePath, + `http://127.0.0.1:${config.vscodePort}`, + ]); + } + } + + return routes; +} + +function buildRouteArgs(routes) { + return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]); +} + +/** + * The editor prefix, if this mode serves it, as `--no-referrer-prefix` args. + * + * agent-server hands the editor a connection token derived from its session + * key and advertises it in the URL's query string, so the workbench document + * must not leak a Referer to the subresources it loads. + */ +function getNoReferrerPrefixArgs(config) { + if (!config.launchAgentServer || !config.vscodeBasePath) return []; + return ["--no-referrer-prefix", config.vscodeBasePath]; +} + +/** + * The editor prefix, if this mode serves it, as `--vscode-base-path` args. + * + * Gated on exactly the same condition as the editor route in + * `getLocalServiceRoutes`, because they answer the same question: an origin + * advertises the editor if and only if it routes it. static-server enforces + * that pairing at startup, so a future edit that breaks it fails loudly rather + * than shipping a control that opens the SPA. + */ +function getVSCodeAdvertiseArgs(config) { + if (!config.launchAgentServer || !config.vscodeBasePath) return []; + return ["--vscode-base-path", config.vscodeBasePath]; +} + +/** + * Build --reject-prefix args for the static server. + * In frontend-only mode, API paths that have no backend should return 503 + * instead of being SPA-fallbacked to index.html. + */ +function getRejectPrefixes(config) { + const prefixes = []; + if (!config.launchAutomation) { + prefixes.push(AUTOMATION_ROUTE_PREFIX); + } + if (!config.launchAgentServer) { + for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { + prefixes.push(prefix); + } + // No agent-server means no editor behind this prefix either. Reject it + // rather than SPA-fallbacking to index.html, which would answer an editor + // request with the canvas shell. + if (config.vscodeBasePath) { + prefixes.push(config.vscodeBasePath); + } + } + return prefixes; +} + +function buildRejectPrefixArgs(prefixes) { + return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]); +} + +function getFrontendBackend(config) { + return config.launchFrontend ? `http://localhost:${config.vitePort}` : null; +} + +function buildViteBackendEnv(config, env = process.env) { + // VITE_BACKEND_HOST tells the Vite dev-server proxy (vite.config.ts) where + // to forward /api, /sockets, etc. It is NOT read by the frontend at + // runtime, so it is safe to keep as an absolute address. + // + // VITE_BACKEND_BASE_URL is intentionally left unset so the frontend falls + // back to window.location.origin (same-origin) at runtime — matching the + // behaviour of dev:static / agent-canvas and keeping the dev server + // portable across localhost, LAN hosts, SSH tunnels, and ngrok. + const backendHost = config.launchAgentServer + ? `127.0.0.1:${config.ingressPort}` + : (env.VITE_BACKEND_HOST ?? + env.VITE_BACKEND_BASE_URL?.replace(/^https?:\/\//, "") ?? + "127.0.0.1:8000"); + + const env_out = { VITE_BACKEND_HOST: backendHost }; + + // If the user supplied VITE_BACKEND_BASE_URL with an https:// scheme and + // did not explicitly set VITE_USE_TLS, propagate the HTTPS intent so the + // Vite proxy forwards over TLS instead of plain HTTP. + if ( + !config.launchAgentServer && + env.VITE_BACKEND_BASE_URL?.startsWith("https://") && + env.VITE_USE_TLS === undefined + ) { + env_out.VITE_USE_TLS = "true"; + } + + return env_out; +} + +function buildAgentServerAutomationEnv(config) { + return { + // Make the session API key available to terminal commands spawned by the + // agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds + // this into Settings > 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.sessionApiKey, + }; +} + +function buildAutomationTelemetryEnv(env = process.env) { + const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1"; + const apiKey = + env.AUTOMATION_POSTHOG_API_KEY || + env.VITE_POSTHOG_API_KEY || + (telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY); + + if (!apiKey) return {}; + + return { + AUTOMATION_POSTHOG_API_KEY: apiKey, + AUTOMATION_POSTHOG_HOST: + env.AUTOMATION_POSTHOG_HOST || + env.VITE_POSTHOG_HOST || + DEFAULT_POSTHOG_HOST, + }; +} + +function startAgentServer(config) { + logService( + "agent-server", + `Starting on port ${config.agentServerPort}...`, + c.blue, + ); + + const agentServerCmd = buildAgentServerCommand(process.env); + logService("agent-server", `Using ${agentServerCmd.source}`, c.dim); + + // Build safe config for agent-server env vars + const safeConfig = buildSafeDevConfig(config.canvasPath, { + ...process.env, + OH_CANVAS_SAFE_STATE_DIR: config.stateDir, + OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(), + OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(), + }); + + const agentServerEnv = { + // Opt into prefix-mode: `getLocalServiceRoutes` registers the matching + // route on both the static server and the ingress, so the prefix this + // advertises resolves to the editor port on the canvas origin. + ...buildAgentServerEnv(safeConfig, { + vscodeBasePath: config.vscodeBasePath, + }), + ...buildAgentServerAutomationEnv(config), + OPENHANDS_REMOTE_WS_READY_REQUIRED: + process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", + // Ensure the agent-server uses the resolved key from config. This is + // LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key. + OH_SESSION_API_KEYS_0: config.sessionApiKey, + // Emit structured JSON log lines instead of Rich-formatted output. + // Rich wraps long messages across multiple lines and prepends its own + // timestamp; LOG_JSON=true produces one JSON object per record which + // parseAgentServerLogLine re-formats into a clean single-line entry. + LOG_JSON: "true", + }; + + spawnService( + "agent-server", + agentServerCmd.command, + [ + ...agentServerCmd.args, + "--host", + "127.0.0.1", + "--port", + String(config.agentServerPort), + ], + { + cwd: safeConfig.workspacesPath, + env: agentServerEnv, + color: c.blue, + parseLogLine: parseAgentServerLogLine, + }, + ); +} + +function startAutomationBackend(config) { + logService( + "automation", + `Starting on port ${config.autoBackendPort}...`, + c.green, + ); + + const automationCmd = buildAutomationCommand(process.env); + logService("automation", `Using ${automationCmd.source}`, c.dim); + + spawnService( + "automation", + automationCmd.command, + [ + ...automationCmd.args, + "--host", + "127.0.0.1", + "--port", + config.autoBackendPort.toString(), + ], + { + cwd: config.stateDir, + env: { + // Force UTF-8 for all Python file I/O (same reason as agent-server; + // see buildAgentServerEnv in dev-safe.mjs). + PYTHONUTF8: "1", + OPENHANDS_REMOTE_WS_READY_REQUIRED: + process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", + // The URL the automation backend itself uses to call the + // agent-server's REST API (tarball upload + bash dispatch). + // + // Priority: + // 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env + // 2. `127.0.0.1:` + AUTOMATION_AGENT_SERVER_URL: + process.env.AUTOMATION_AGENT_SERVER_URL || + getAgentServerBaseUrl(config), + // The URL exported into the in-sandbox bash chain as + // `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into + // the agent-server). + // + // Priority: + // 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env + // 2. launcher-provided value + // 3. unset — backend falls back to AUTOMATION_AGENT_SERVER_URL + ...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL || + config.sandboxAgentServerUrl + ? { + AUTOMATION_SANDBOX_AGENT_SERVER_URL: + process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL || + config.sandboxAgentServerUrl, + } + : {}), + AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, + // ~/.openhands/automation/automations.db — matches docker/entrypoint.sh. + AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`, + // The automation backend uses this as its publicly-reachable base + // URL: it's appended to callback URLs and injected into each + // sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for + // /sdk-version and by the SDK for run completion). + // Priority: + // 1. AUTOMATION_BASE_URL explicitly set in the user's env + // 2. launcher-provided host + // 3. `localhost` + AUTOMATION_BASE_URL: + process.env.AUTOMATION_BASE_URL || + `http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`, + // The dispatcher resolves this path and embeds it into a + // `mkdir -p ...` shell command executed by the agent-server. + // Priority: + // 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env + // 2. `automationWorkspaceBase` option passed by the launcher + // 3. host-side default under config.stateDir + AUTOMATION_WORKSPACE_BASE: + process.env.AUTOMATION_WORKSPACE_BASE || + config.automationWorkspaceBase || + join(config.stateDir, "workspaces"), + // Session API key for self-hosted auth — shared with agent-server via X-Session-API-Key header + AUTOMATION_LOCAL_API_KEY: config.sessionApiKey, + ...buildAutomationTelemetryEnv(), + // KV store secret — required for automations to use the built-in + // key-value store for state persistence between runs. Used for JWT + // signing and value encryption. + // Priority: + // 1. AUTOMATION_KV_SECRET explicitly set in the user's env + // 2. sessionApiKey — convenient zero-config default for local dev + AUTOMATION_KV_SECRET: + process.env.AUTOMATION_KV_SECRET || config.sessionApiKey, + // CORS: allow localhost origins for dev, unless explicitly overridden. + AUTOMATION_CORS_ORIGINS: + process.env.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"), + OPENHANDS_SUPPRESS_BANNER: "1", + }, + color: c.green, + }, + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════════════════ + +let shuttingDown = false; + +function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + + console.log(""); + console.log(`${c.yellow}Shutting down...${c.reset}`); + fileLog("info", "Shutting down..."); + + for (const [name, proc] of processes) { + logService(name, "Stopping...", c.dim); + signalProcessTree(proc, "SIGTERM"); + } + + setTimeout(() => { + for (const [name, proc] of processes) { + if (isProcessRunning(proc)) { + logService(name, "Force stopping...", c.dim); + signalProcessTree(proc, "SIGKILL"); + } + } + shutdownHooks.run(); + process.exit(0); + }, 3000); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); +process.on("SIGHUP", shutdown); + +function startIngress(config) { + logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); + + const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); + const frontendBackend = getFrontendBackend(config); + const runtimeServicesInfo = config.launchAgentServer + ? JSON.stringify(buildAutomationRuntimeServicesInfo(config)) + : null; + + spawnService( + "ingress", + "node", + [ + ingressScript, + "--port", + config.ingressPort.toString(), + ...(runtimeServicesInfo + ? ["--runtime-services-info", runtimeServicesInfo] + : []), + ...buildRouteArgs(getLocalServiceRoutes(config)), + ...getNoReferrerPrefixArgs(config), + ...(frontendBackend ? ["--default", frontendBackend] : []), + ], + { + cwd: projectRoot, + color: c.yellow, + }, + ); +} + +/** + * Build the JSON-serializable runtime services info for an automation + * stack. Backend-serving processes append this to `/server_info` so any + * frontend connected to the backend can populate the agent's + * `` system-prompt block. + */ +export function buildAutomationRuntimeServicesInfo(config) { + return buildRuntimeServicesInfo({ + mode: config.mode ?? "dev:automation", + agentHostAlias: config.agentHostAlias ?? "localhost", + agentServerPort: config.agentServerPort, + ingressPort: config.ingressPort, + frontendPort: config.launchFrontend ? config.vitePort : undefined, + // The same port hosts Vite in dynamic mode and a static-file server + // in static mode. The launcher records this on the config so the + // description shown to the agent matches reality. + frontendKind: config.frontendKind ?? "vite", + automation: config.launchAutomation + ? { port: config.autoBackendPort } + : undefined, + }); +} + +function startVite(config) { + logService("vite", `Starting on port ${config.vitePort}...`, c.magenta); + + const frontendCommand = buildNpmScriptCommand("dev:frontend"); + + const viteEnv = { + // Full-stack mode points Vite at this launcher's ingress. Frontend-only + // mode uses the separately running backend ingress instead. + ...buildViteBackendEnv(config), + VITE_FRONTEND_PORT: config.vitePort.toString(), + }; + if (config.viteWorkingDir) { + viteEnv.VITE_WORKING_DIR = config.viteWorkingDir; + } + + // Vite serves the HTML for this mode's browser origin, so this is where the + // editor-capability advertisement has to be baked. The ingress in front of it + // routes the prefix but is a pure proxy — it injects nothing into the + // document, so it cannot tell the frontend what it serves. + // + // Both variables or neither: `vite.config.ts` only registers the editor proxy + // when it has a target as well as a prefix, and this stack has two supported + // browser origins — the ingress and Vite's own port, which is why the latter + // is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the + // ingress itself; on the Vite origin only this proxy can serve it. Baking the + // prefix alone would advertise an editor on the Vite origin whose URL then + // falls through to the SPA — the dead button this gating exists to prevent. + if (config.launchAgentServer && config.vscodeBasePath) { + viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath; + viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`; + } + + // In local mode, bake the session key into the frontend so the user + // never has to paste it. In public mode, omit the key and set + // VITE_AUTH_REQUIRED so the frontend shows the API key entry screen + // immediately (no network round-trip needed). + if (config.launchAgentServer && config.isPublic) { + viteEnv.VITE_AUTH_REQUIRED = "true"; + } else if (config.launchAgentServer) { + viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey; + } + + spawnService("vite", frontendCommand.command, frontendCommand.args, { + cwd: config.canvasPath, + env: viteEnv, + color: c.magenta, + }); +} + +/** + * Seed the session API key into agent-server's secrets store as + * OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the + * automation backend in curl commands during conversations. + * + * Includes retry logic to handle slow server startup or transient failures. + * + * @param {object} config - Configuration object with agentServerPort, sessionApiKey + * @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) + * @param {number} options.timeoutMs - Request timeout in ms (default: 10000) + * @returns {Promise} True if seeding succeeded, false otherwise + */ +async function seedAutomationSecret(config, options = {}) { + const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options; + + const secretName = "OPENHANDS_AUTOMATION_API_KEY"; + const secretDescription = + "API key for authenticating with the automation backend"; + + logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim); + + const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`; + const body = JSON.stringify({ + name: secretName, + value: config.sessionApiKey, + description: secretDescription, + }); + + const headers = { + "Content-Type": "application/json", + // Include session API key if configured + ...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }), + }; + + let lastError = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: "PUT", + headers, + body, + signal: AbortSignal.timeout(timeoutMs), + }); + + if (response.ok) { + logService("secrets", `${secretName} seeded successfully`, c.green); + return true; + } + + const text = await response.text(); + lastError = `HTTP ${response.status}: ${text}`; + + // Don't retry on authentication errors - they won't resolve with retries + if (response.status === 401 || response.status === 403) { + logService( + "secrets", + `Warning: Failed to seed secret (${response.status}): ${text}`, + c.yellow, + ); + return false; + } + + // Retry on server errors or service unavailable + if (attempt < maxRetries) { + logService( + "secrets", + `Retry ${attempt}/${maxRetries} after ${response.status}...`, + c.dim, + ); + await delay(retryDelayMs); + } + } catch (err) { + lastError = err.message; + + // Connection errors likely mean server isn't ready - wait and retry + if (attempt < maxRetries) { + logService( + "secrets", + `Retry ${attempt}/${maxRetries}: ${err.message}`, + c.dim, + ); + await delay(retryDelayMs); + } + } + } + + logService( + "secrets", + `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`, + c.yellow, + ); + return false; +} + +function printBanner(config) { + const stackName = config.frontendOnly + ? "Agent Canvas Frontend Stack" + : config.backendOnly + ? "Agent Canvas Backend Stack" + : "Agent Canvas + Automation Stack"; + + // padEnd counts invisible ANSI escape bytes as visible characters, so we + // compute the visible length separately and pad with spaces accordingly. + const ansiEscape = String.fromCharCode(27); + const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g"); + const ansiPadEnd = (str, targetVisible) => { + const visible = str.replace(ansiRe, "").length; + return str + " ".repeat(Math.max(0, targetVisible - visible)); + }; + // The box has 62-char inner width; each content line needs 63 visible chars + // before the trailing border (1 leading ║ + 62 inner). + const BOX_INNER = 63; + + console.log(""); + console.log( + `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`, + ); + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} ${c.bold}${stackName}${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`, + ); + console.log( + `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, + ); + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + if (config.launchFrontend) { + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + } + if (config.launchAutomation) { + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + } + console.log( + `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, + ); + console.log( + `${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`, + ); + console.log(""); + console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`); + console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`); + console.log(""); + + // Write a compact plain-text summary to the log file. + const summary = [ + `${stackName} — started`, + ` Ingress: http://localhost:${config.ingressPort}/`, + ...(config.launchFrontend + ? [` Main UI: http://localhost:${config.ingressPort}/`] + : []), + ...(config.launchAutomation + ? [ + ` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`, + ] + : []), + ` State directory: ${config.stateDir}`, + ]; + fileLog("info", summary.join("\n")); +} + +async function main(options = {}) { + const { + bannerTitle = "Agent Canvas + Automation Development Stack", + startAgentServer: startAgentServerOverride, + extraPrereqs, + viteWorkingDir, + // Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend. + // Defaults to a host-side path under config.stateDir. + automationWorkspaceBase, + // Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox + // uses to call back into the automation backend). Defaults to `localhost`. + automationApiHost, + // Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the + // automation backend. This is the URL the in-sandbox bash chain uses + // to reach the agent-server. When unset the backend falls back to + // AUTOMATION_AGENT_SERVER_URL. + sandboxAgentServerUrl, + staticMode: staticModeOverride, + defaultStaticMode = false, + buildStaticFrontend, + staticDir: staticDirOverride, + // Hostname the agent uses to reach services running on the host. + agentHostAlias = "localhost", + // Human-readable label for the dev mode, surfaced in the agent's + // system-prompt block. + mode = "dev:automation", + // When true, enable public mode (require LOCAL_BACKEND_API_KEY, + // don't bake session key into frontend). + isPublic: isPublicOverride, + // When true, skip the npm prerequisite check. Used by the Electron desktop + // launcher where npm is not needed at runtime in static mode. + skipNpmCheck = false, + // How long to wait for the agent-server's `/server_info` to return 200 + // before continuing. Defaults to 60 s, which is fine for warm-cache dev + // workflows. The Electron desktop launcher bumps this to several minutes + // because first-launch on a fresh machine runs `uvx` to download Python + // and install `openhands-agent-server` from PyPI, which can take much + // longer than 60 s on a slow network. + agentServerReadyTimeoutMs = 60_000, + // Optional `(name, line, level)` callback that receives every service log + // line (stdout, stderr, and lifecycle events) emitted by any spawned + // backend process. Used by the Electron loading screen to surface uvx + // download / install progress to the user. `level` is one of + // "stdout" | "stderr" | "info" | "warn" | "error". + onServiceLog, + } = options; + + // Install the listener early so log lines emitted before the first + // `spawnService` call (e.g. by future setup steps) are also captured. + setServiceLogListener(onServiceLog); + + const args = parseArgs(); + + // Allow options to override CLI args for public mode + if (isPublicOverride != null) { + args.public = isPublicOverride; + } + + // Allow options to override CLI args (for bin/agent-canvas.mjs) + const useStaticMode = + staticModeOverride ?? + (args.dynamic ? false : args.static || defaultStaticMode); + const staticDir = + staticDirOverride ?? args.staticDir ?? join(projectRoot, "build"); + + const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : ""; + const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle; + + console.log(""); + console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`); + console.log(""); + fileLog("info", titleWithMode); + + // Setup phase + checkPrerequisites({ + checkUvx: !args.frontendOnly, + // Static-mode + backend-only has no frontend to build, so npm is not + // required — unless the caller provides a custom buildStaticFrontend hook. + // The Electron desktop launcher passes `skipNpmCheck: true` because the + // packaged binary serves a pre-built static frontend and never invokes + // npm at runtime, so we suppress the check unconditionally there. + checkNpm: + !skipNpmCheck && + ((!useStaticMode && !args.backendOnly) || + typeof buildStaticFrontend === "function"), + checkFrontendDependencies: + (!useStaticMode && !args.backendOnly) || + typeof buildStaticFrontend === "function", + }); + + // Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste + // time allocating ports / generating keys / launching uvx with a path that + // would only produce a cryptic build error. Mirrors dev-safe.mjs and + // dev-extra-backend.mjs. + if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) { + try { + validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); + } catch (error) { + logError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } + + // Same for the automation checkout -- skipped when --automation-git-ref was + // passed, since buildConfig drops the env var in favor of the explicit flag. + if ( + !args.frontendOnly && + !args.automationGitRef && + process.env.OH_AUTOMATION_LOCAL_PATH + ) { + try { + validateLocalAutomationPath(process.env.OH_AUTOMATION_LOCAL_PATH); + } catch (error) { + logError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } + + // Build config with dynamic port allocation + const config = await buildConfig(args); + if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir; + if (automationWorkspaceBase) { + config.automationWorkspaceBase = automationWorkspaceBase; + } + if (automationApiHost) { + config.automationApiHost = automationApiHost; + } + if (sandboxAgentServerUrl) { + config.sandboxAgentServerUrl = sandboxAgentServerUrl; + } + // Stamp the dev-mode label, host alias, and frontend kind on the config + // so downstream helpers (Vite spawn, static build) can produce a + // runtime-services info object describing what the agent can reach. + config.mode = mode; + config.agentHostAlias = agentHostAlias; + config.frontendKind = useStaticMode ? "static" : "vite"; + ensureDirectories(config); + if (typeof extraPrereqs === "function") { + extraPrereqs(config); + } + + if ( + config.launchFrontend && + useStaticMode && + typeof buildStaticFrontend === "function" + ) { + buildStaticFrontend(config, args); + } + + // In static mode, verify build exists after any launcher-managed build. + if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) { + logError(`Static directory not found: ${staticDir}`); + logError(`Run 'npm run build' first to create the static files.`); + process.exit(1); + } + + // Start services phase + logStep("2/2", "Starting services..."); + + let agentServerReady = false; + + // 1. Start agent-server first (automation depends on it). + // + // Readiness timeout defaults to 60 s, which is fine for `npm run dev` against + // a warm uvx cache. The Electron desktop launcher overrides this via the + // `agentServerReadyTimeoutMs` option because first-launch on a fresh machine + // runs `uvx` to download Python + install `openhands-agent-server` from PyPI, + // which can take several minutes. Dropping the user into a half-booted UI + // before that completes triggers axios "Request timeout" popups on the first + // SPA fetch that hits an unbound port 18000. + if (config.launchAgentServer) { + const agentServerStarter = startAgentServerOverride ?? startAgentServer; + agentServerStarter(config); + + agentServerReady = await waitForService( + "agent-server", + `${getAgentServerBaseUrl(config)}/server_info`, + agentServerReadyTimeoutMs, + ); + } + + // 2. Seed automation API key into agent-server secrets + // This makes the key available to agents during conversations + // Note: seedAutomationSecret has its own retry logic if server is still warming up + if (config.launchAutomation && agentServerReady) { + await seedAutomationSecret(config); + } else if (config.launchAutomation) { + logService( + "secrets", + "Skipping secret seeding - agent-server not ready", + c.yellow, + ); + } + + // 3. Start automation backend + if (config.launchAutomation) { + startAutomationBackend(config); + } + + // 4. Start frontend server (Vite dev server OR static server) + if (config.launchFrontend) { + if (useStaticMode) { + startStaticFrontend(config, staticDir); + } else { + startVite(config); + } + } + + // 5. Wait for services to be ready + await delay(2000); + + // 6. Start ingress proxy (routes traffic only to running services) + startIngress(config); + + // Wait for ingress to start + await delay(1000); + + printBanner(config); + + // Return the resolved config + readiness signal so embedded launchers can + // (a) build URLs from the actual allocated ports and (b) decide whether to + // show an error to the user when the agent-server never came up. + return { config, agentServerReady }; +} + +function startStaticFrontend(config, staticDir) { + logService("static", `Starting on port ${config.vitePort}...`, c.magenta); + logService("static", `Serving from: ${staticDir}`, c.dim); + + // Build the runtime-services info JSON so static-server can append it to + // /server_info. The static-server also injects the old window global for + // compatibility with previously built frontend bundles. + const runtimeServicesInfo = config.launchAgentServer + ? JSON.stringify(buildAutomationRuntimeServicesInfo(config)) + : null; + + const staticServerScript = join(projectRoot, "scripts", "static-server.mjs"); + spawnService( + "static", + "node", + [ + staticServerScript, + "--dir", + staticDir, + "--port", + String(config.vitePort), + ...(process.env.VITE_BASE_PATH + ? ["--base-path", process.env.VITE_BASE_PATH] + : []), + // In local mode, inject the API key so the pre-built frontend can + // authenticate transparently. In public mode, pass --auth-required + // so the frontend shows the API key entry screen instead. + ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey + ? ["--session-api-key", config.sessionApiKey] + : []), + ...(config.launchAgentServer && config.isPublic + ? ["--auth-required"] + : []), + // Inject runtime-services info so the agent knows what's reachable. + ...(runtimeServicesInfo + ? ["--runtime-services-info", runtimeServicesInfo] + : []), + // Proxy routes only to services that this launch mode started. + ...buildRouteArgs(getLocalServiceRoutes(config)), + // Only the static server injects into the document, so only it can tell + // the frontend this origin serves the editor. The ingress routes the same + // prefix but proxies the HTML through untouched. + ...getVSCodeAdvertiseArgs(config), + ...getNoReferrerPrefixArgs(config), + // Reject known API prefixes that have no backend — returns 503 + // instead of SPA-fallbacking to index.html. + ...buildRejectPrefixArgs(getRejectPrefixes(config)), + ], + { + cwd: config.canvasPath, + color: c.magenta, + }, + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Exports for testing +// ═══════════════════════════════════════════════════════════════════════════ + +export { + buildAgentServerAutomationEnv, + buildAutomationCommand, + buildAutomationTelemetryEnv, + buildConfig, + buildRouteArgs, + buildViteBackendEnv, + getAgentServerBaseUrl, + getFrontendBackend, + getLocalServiceRoutes, + getNoReferrerPrefixArgs, + getRejectPrefixes, + getVSCodeAdvertiseArgs, + main, + registerShutdownHook, + spawnService, + commandExists, + validateLocalAutomationPath, + logService, + logStep, + logSuccess, + logError, + c, + DEFAULT_AUTOMATION_REPO, + DEFAULT_AUTOMATION_PACKAGE, + DEFAULT_AUTOMATION_VERSION, + DEFAULT_AUTOMATION_SDK_VERSION, + DEFAULT_BACKEND_PORT, + DEFAULT_AUTOMATION_PORT, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Main entry point (only when run directly, not when imported) +// ═══════════════════════════════════════════════════════════════════════════ + +// Check if this module is the main entry point +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + main().catch((err) => { + logError(`Fatal error: ${err.message}`); + if (err.stack) { + console.error(c.dim + err.stack + c.reset); + fileLog("error", err.stack); + } + process.exit(1); + }); +} diff --git a/scripts/docker-build.mjs b/scripts/docker-build.mjs new file mode 100644 index 0000000000000000000000000000000000000000..66a411c95d1960380bc224f45914b8b15167eedb --- /dev/null +++ b/scripts/docker-build.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * Local Docker build helper. + * + * Reads version pins from config/defaults.json and invokes `docker build` + * with the correct --build-arg values so developers never need to remember + * (or hardcode) version strings. + * + * Usage: + * node scripts/docker-build.mjs # defaults + * node scripts/docker-build.mjs --tag my-tag # custom tag + * node scripts/docker-build.mjs -- --no-cache # extra docker args + */ +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +const config = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), +); + +const agentServerImage = `${config.images.agentServer}:${config.versions.agentServer}-python`; +const automationVersion = config.versions.automation; +const canvasBasePath = config.paths.canvasBasePath; + +// Parse CLI: --tag and everything after -- is passed to docker build +let tag = "agent-canvas:local"; +const extraArgs = []; +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i++) { + if (args[i] === "--tag" && i + 1 < args.length) { + tag = args[++i]; + } else if (args[i] === "--") { + extraArgs.push(...args.slice(i + 1)); + break; + } else { + extraArgs.push(args[i]); + } +} + +const cmd = [ + "docker", + "build", + "-f", + "docker/Dockerfile", + "--build-arg", + `AGENT_SERVER_IMAGE=${agentServerImage}`, + "--build-arg", + `AUTOMATION_VERSION=${automationVersion}`, + "--build-arg", + `VITE_BASE_PATH=${canvasBasePath}`, + "-t", + tag, + ...extraArgs, + ".", +]; + +console.log(`Agent Server image : ${agentServerImage}`); +console.log(`Automation version : ${automationVersion}`); +console.log(`Canvas base path : ${canvasBasePath}`); +console.log(`Tag : ${tag}`); +console.log(`\n$ ${cmd.join(" ")}\n`); + +try { + execFileSync(cmd[0], cmd.slice(1), { + cwd: projectRoot, + stdio: "inherit", + }); +} catch (err) { + process.exit(err.status || 1); +} diff --git a/scripts/download-node.mjs b/scripts/download-node.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6826ef602bf2ba5522e176dc16a1196aad71244f --- /dev/null +++ b/scripts/download-node.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node +/** + * Download the official Node.js distribution for the current platform into + * resources/node/, so electron-builder can bundle it as an extraResource. + * + * The packaged Electron desktop app uses this bundled Node to provide + * `node`, `npm`, and `npx` to spawned subprocesses — most importantly the + * stdio MCP servers in the marketplace (Slack, GitHub, Figma, etc.) whose + * commands start with `npx -y `. + * + * Why bundle Node instead of using Electron-as-Node (ELECTRON_RUN_AS_NODE=1)? + * + * We tried that first. Electron-as-Node works fine for our backend + * helper scripts (static-server.mjs, ingress.mjs) which mostly do + * networking, but it is **not** reliable for stdio JSON-RPC servers. + * When npx-cli.js (running under Electron-as-Node) spawned the MCP + * server, the child's stdin pipe semantics differed from vanilla Node + * on macOS (the parent is a windowed Electron process, not a clean + * command-line Node binary) — the server appeared to start, then + * immediately exited with "McpError: Connection closed" before the + * first JSON-RPC handshake message could land. Bundling the real + * Node binary sidesteps all of that. + * + * The downloaded Node.js distribution already includes npm and npx at + * `bin/npm` / `bin/npx` (POSIX) or `npm.cmd` / `npx.cmd` (Windows), so we + * do **not** need a separate npm download (this script supersedes the + * earlier download-npm.mjs). + * + * Usage: + * node scripts/download-node.mjs # uses NODE_BUNDLE_VERSION below + * NODE_VERSION=22.10.0 node scripts/download-node.mjs + * + * Output (per platform): + * POSIX: resources/node/bin/{node,npm,npx} + resources/node/lib/node_modules/npm/... + * Windows: resources/node/{node.exe,npm.cmd,npx.cmd} + resources/node/node_modules/npm/... + */ + +import { + chmodSync, + createWriteStream, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readlinkSync, + rmSync, + statSync, + unlinkSync, +} from "node:fs"; +import { get } from "node:https"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); +const outDir = join(projectRoot, "resources", "node"); + +// Pinned Node version. Electron 42 ships Node 22, so we bundle a 22.x +// LTS release to match the embedded runtime's ABI/native-module surface. +// We intentionally use 22.12.0 — the repo's own support floor +// (package.json engines.node >=22.12.0, volta 22.12.0) — rather than +// Electron 42.3.2's exact embedded Node patch level: the bundled binary +// runs this repo's launcher scripts, and native modules only need ABI +// parity (NODE_MODULE_VERSION 127, shared by all 22.x builds). +// Override at build time with NODE_VERSION=… (e.g. to test against a +// newer release). Major version >=22 only; engines.node in npm 10.x +// requires ^18.17.0 || >=20.5.0. +const NODE_BUNDLE_VERSION = "22.12.0"; + +// ── Platform detection ─────────────────────────────────────────────────────── + +const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32' +const ARCH = process.arch; // 'x64' | 'arm64' | 'ia32' + +/** + * Map (platform, arch) → Node's published distribution name. + * Names come straight from https://nodejs.org/dist//. + * + * macOS arm64 → node-v-darwin-arm64.tar.gz + * macOS x64 → node-v-darwin-x64.tar.gz + * linux x64 → node-v-linux-x64.tar.gz + * linux arm64 → node-v-linux-arm64.tar.gz + * win32 x64 → node-v-win-x64.zip + * win32 arm64 → node-v-win-arm64.zip + */ +function getPlatformSpec(version) { + const base = `node-v${version}`; + if (PLATFORM === "darwin") { + const arch = ARCH === "arm64" ? "arm64" : "x64"; + return { name: `${base}-darwin-${arch}`, ext: "tar.gz" }; + } + if (PLATFORM === "linux") { + const arch = ARCH === "arm64" ? "arm64" : "x64"; + return { name: `${base}-linux-${arch}`, ext: "tar.gz" }; + } + if (PLATFORM === "win32") { + const arch = ARCH === "arm64" ? "arm64" : "x64"; + return { name: `${base}-win-${arch}`, ext: "zip" }; + } + throw new Error(`Unsupported platform for Node download: ${PLATFORM}/${ARCH}`); +} + +// ── Version resolution ─────────────────────────────────────────────────────── + +function resolveVersion() { + const requested = process.env.NODE_VERSION?.replace(/^v/, ""); + return requested || NODE_BUNDLE_VERSION; +} + +// ── HTTP helpers ───────────────────────────────────────────────────────────── + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + function doGet(u) { + get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return doGet(res.headers.location); + } + if (res.statusCode !== 200) { + file.destroy(); + return reject(new Error(`GET ${u} → HTTP ${res.statusCode}`)); + } + res.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + res.on("error", reject); + }).on("error", (err) => { + file.destroy(); + reject(err); + }); + } + doGet(url); + }); +} + +// ── Extraction ─────────────────────────────────────────────────────────────── + +function extract(archivePath, targetDir, ext) { + // Both tar.gz and zip extract via system `tar`: + // GNU/BSD tar (macOS/Linux) handles .tar.gz natively. + // bsdtar (Windows 10+) handles both .tar.gz and .zip. + // --strip-components=1 drops the "node-vX.Y.Z--/" top dir. + void ext; // archive content is identified by tar's own magic bytes + execFileSync( + "tar", + ["-xf", archivePath, "-C", targetDir, "--strip-components=1"], + { stdio: "inherit" }, + ); +} + +function ensureExecutable(p) { + if (process.platform === "win32") return; + try { + chmodSync(p, 0o755); + } catch {} +} + +// ── Layout verification ────────────────────────────────────────────────────── + +/** + * Confirm the extracted tree has the binaries we depend on. + * On Unix Node puts them in bin/; on Windows they live at the root. + */ +function verifyLayout() { + const isWin = PLATFORM === "win32"; + const required = isWin + ? ["node.exe", "npm.cmd", "npx.cmd"] + : ["bin/node", "bin/npm", "bin/npx"]; + for (const rel of required) { + const p = join(outDir, rel); + if (!existsSync(p)) { + throw new Error( + `Expected ${rel} in extracted Node distribution but it is missing ` + + `at ${p}. Did the tarball layout change?`, + ); + } + ensureExecutable(p); + } + + // npm/npx are wrapper scripts that invoke node against npm's JS entry + // points; verify the targets exist too so a packaged build doesn't ship + // a half-broken installation. + const npmCli = isWin + ? join(outDir, "node_modules", "npm", "bin", "npm-cli.js") + : join(outDir, "lib", "node_modules", "npm", "bin", "npm-cli.js"); + if (!existsSync(npmCli)) { + throw new Error(`Bundled Node is missing npm-cli.js at ${npmCli}`); + } +} + +// ── Pruning ────────────────────────────────────────────────────────────────── + +/** + * Drop pieces of the Node distribution that are only useful when building + * native modules from source or for human-readable documentation. Stripping + * these shrinks the bundled Node from ~170 MB → ~115 MB on Linux x64 (the + * Node binary itself is the bulk of what remains and can't be reduced). + * + * Kept intentionally: + * bin/node, bin/npm, bin/npx — runtime binaries / wrappers + * lib/node_modules/{npm,corepack} — npm itself + * LICENSE — required by the BSD-style Node license + */ +function pruneUnusedFiles() { + // IMPORTANT: every entry that points into a directory we delete must also + // delete any symlink/shim that targets into it, otherwise electron-builder + // hits ENOENT trying to stat() the dangling symlink while copying the + // extraResource into the .app bundle. + // + // Example: Node's POSIX tarball ships `bin/corepack` as a symlink to + // `../lib/node_modules/corepack/dist/corepack.js`. If we drop the corepack + // module under lib/ but leave the symlink, `electron-builder` fails with + // ENOENT: ... Resources/node/bin/corepack + const candidates = + PLATFORM === "win32" + ? [ + // Windows Node zip lays out npm directly under node_modules/, not lib/. + // Strip docs, headers, and node_modules/corepack (the npm runtime + // doesn't need corepack to run, and we don't ship yarn/pnpm). + "CHANGELOG.md", + "README.md", + "node_modules/corepack", + // Windows ships corepack as both a Bash wrapper and a cmd.exe wrapper + // at the distribution root; both proxy into node_modules/corepack. + "corepack", + "corepack.cmd", + ] + : [ + // POSIX layout — keep bin/ and lib/node_modules/npm; drop the rest. + "include", + "share", + "CHANGELOG.md", + "README.md", + "lib/node_modules/corepack", + // Symlink in bin/ targets the corepack we just deleted. + "bin/corepack", + ]; + for (const rel of candidates) { + const p = join(outDir, rel); + // `rmSync(force: true)` resolves the path through symlinks, so once the + // corepack target directory is deleted the now-dangling `bin/corepack` + // link reads as "already gone" and silently survives — the exact ENOENT + // trap failOnDanglingSymlinks() exists to catch. Remove files/symlinks + // with `unlinkSync` (lstat semantics, works on dangling links) first and + // fall back to `rmSync` for directories. + try { + unlinkSync(p); + } catch { + try { + rmSync(p, { recursive: true, force: true }); + } catch { + // best-effort + } + } + } +} + +// ── Dangling-symlink check ─────────────────────────────────────────────────── + +/** + * Walk the pruned tree and refuse to finish if any symlink points at a path + * that no longer exists. electron-builder calls `stat()` (which follows + * symlinks) on every entry it copies into the .app bundle, so a single + * dangling symlink blows up the whole `build:desktop` step with a confusing + * ENOENT — fail at download time instead, with a message that says which + * pruned directory the symlink was reaching into. + */ +function failOnDanglingSymlinks() { + const broken = []; + const stack = [outDir]; + while (stack.length) { + const next = stack.pop(); + let entries; + try { + entries = readdirSync(next, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const p = join(next, entry.name); + if (entry.isSymbolicLink()) { + try { + // statSync follows the link; if the target is gone this throws. + statSync(p); + } catch { + let target = ""; + try { + if (lstatSync(p).isSymbolicLink()) target = readlinkSync(p); + } catch { + // ignore — best-effort labelling + } + broken.push(`${p} → ${target}`); + } + } else if (entry.isDirectory()) { + stack.push(p); + } + } + } + if (broken.length) { + console.error( + "[download-node] Dangling symlinks remain after pruning — these would " + + "crash electron-builder later with ENOENT. Add the dangling symlink " + + "(or its target) to pruneUnusedFiles() in this script:", + ); + for (const entry of broken) console.error(" •", entry); + throw new Error(`${broken.length} dangling symlink(s) in resources/node/`); + } +} + +// ── Size report ────────────────────────────────────────────────────────────── + +function dirSizeBytes(dir) { + let total = 0; + const stack = [dir]; + while (stack.length) { + const next = stack.pop(); + let entries; + try { + entries = readdirSync(next, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const p = join(next, entry.name); + if (entry.isDirectory()) { + stack.push(p); + } else { + try { + total += statSync(p).size; + } catch {} + } + } + } + return total; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const version = resolveVersion(); + const spec = getPlatformSpec(version); + const archiveName = `${spec.name}.${spec.ext}`; + const url = `https://nodejs.org/dist/v${version}/${archiveName}`; + const tmpFile = join(tmpdir(), `node-download-${Date.now()}.${spec.ext}`); + + console.log( + `[download-node] Downloading Node v${version} for ${PLATFORM}/${ARCH}`, + ); + console.log(`[download-node] URL: ${url}`); + + try { + // Clear any previous output so stale files (different Node version, or + // a stale resources/npm/ from the previous wrapper approach) don't + // linger in the bundle. + if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + + console.log(`[download-node] Downloading to ${tmpFile}`); + await downloadFile(url, tmpFile); + console.log(`[download-node] Extracting to ${outDir}`); + extract(tmpFile, outDir, spec.ext); + + verifyLayout(); + pruneUnusedFiles(); + failOnDanglingSymlinks(); + + const mb = Math.round(dirSizeBytes(outDir) / (1024 * 1024)); + console.log(`[download-node] ✓ Node v${version} ready at ${outDir} (~${mb} MB)`); + } finally { + try { + rmSync(tmpFile, { force: true }); + } catch {} + } +} + +main().catch((err) => { + console.error("[download-node] Error:", err.message); + process.exit(1); +}); diff --git a/scripts/download-uv.mjs b/scripts/download-uv.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c8fda754fc0daac76926072dc853ff33bfee1b0d --- /dev/null +++ b/scripts/download-uv.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Download the uv binary for the current platform into resources/bin/ + * so that electron-builder can bundle it as an extraResource. + * + * uv provides uvx, which the Electron desktop app uses to run the + * agent-server and automation backend Python packages. + * + * Usage: + * node scripts/download-uv.mjs # uses latest GitHub release + * UV_VERSION=0.7.0 node scripts/download-uv.mjs + * + * Output (per platform): + * resources/bin/uv + resources/bin/uvx (macOS / Linux) + * resources/bin/uv.exe + resources/bin/uvx.exe (Windows) + */ + +import { + chmodSync, + copyFileSync, + createWriteStream, + existsSync, + mkdirSync, + rmSync, +} from "node:fs"; +import { get } from "node:https"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFileSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); +const outDir = join(projectRoot, "resources", "bin"); + +// ── Platform detection ───────────────────────────────────────────────────────── + +const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32' +const ARCH = process.arch; // 'x64' | 'arm64' + +function getPlatformSpec() { + if (PLATFORM === "darwin") { + const uvArch = ARCH === "arm64" ? "aarch64" : "x86_64"; + return { + target: `uv-${uvArch}-apple-darwin`, + ext: "tar.gz", + binaries: ["uv", "uvx"], + }; + } + if (PLATFORM === "linux") { + // Only x64 is officially supported by uv for desktop builds + return { + target: "uv-x86_64-unknown-linux-gnu", + ext: "tar.gz", + binaries: ["uv", "uvx"], + }; + } + if (PLATFORM === "win32") { + return { + target: "uv-x86_64-pc-windows-msvc", + ext: "zip", + binaries: ["uv.exe", "uvx.exe"], + }; + } + throw new Error(`Unsupported platform for uv download: ${PLATFORM}`); +} + +// ── Version resolution ──────────────────────────────────────────────────────── + +async function resolveVersion() { + if (process.env.UV_VERSION) { + return process.env.UV_VERSION.replace(/^v/, ""); + } + + console.log("[download-uv] Fetching latest uv version from GitHub API..."); + const headers = { "User-Agent": "agent-canvas-build" }; + // Unauthenticated api.github.com calls are rate-limited per IP (60/hour) — + // shared CI runner IPs exhaust that fast. CI passes GITHUB_TOKEN. + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + const data = await fetchJson( + "https://api.github.com/repos/astral-sh/uv/releases/latest", + headers + ); + const version = data.tag_name?.replace(/^v/, ""); + if (!version) throw new Error("Could not parse uv version from GitHub API"); + return version; +} + +// ── HTTP helpers ────────────────────────────────────────────────────────────── + +function fetchJson(url, headers = {}) { + return new Promise((resolve, reject) => { + get(url, { headers }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return resolve(fetchJson(res.headers.location, headers)); + } + if (res.statusCode !== 200) { + return reject(new Error(`GET ${url} → HTTP ${res.statusCode}`)); + } + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => resolve(JSON.parse(body))); + res.on("error", reject); + }).on("error", reject); + }); +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + function doGet(u) { + get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return doGet(res.headers.location); + } + if (res.statusCode !== 200) { + file.destroy(); + return reject(new Error(`GET ${u} → HTTP ${res.statusCode}`)); + } + res.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + res.on("error", reject); + }).on("error", (err) => { + file.destroy(); + reject(err); + }); + } + doGet(url); + }); +} + +// ── Extraction ──────────────────────────────────────────────────────────────── + +function extract(archivePath, targetDir, ext) { + // Both tar.gz and zip are handled by the system 'tar' command: + // macOS/Linux: GNU/BSD tar natively supports .tar.gz + // Windows 10+: built-in bsdtar supports both .tar.gz and .zip + // The tar.gz archives wrap uv/uvx in a top-level `uv-/` directory, + // which --strip-components=1 removes. uv's Windows .zip is flat (uv.exe / + // uvx.exe at the archive root) — stripping there would skip every entry + // and extract nothing. + const args = ["-xf", archivePath, "-C", targetDir]; + if (ext === "tar.gz") { + args.push("--strip-components=1"); + } + execFileSync("tar", args, { stdio: "inherit" }); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +async function main() { + const spec = getPlatformSpec(); + const version = await resolveVersion(); + + console.log(`[download-uv] Downloading uv v${version} for ${PLATFORM}/${ARCH}`); + + const filename = `${spec.target}.${spec.ext}`; + const url = `https://github.com/astral-sh/uv/releases/download/${version}/${filename}`; + const tmpFile = join(tmpdir(), `uv-download-${Date.now()}.${spec.ext}`); + const extractDir = join(tmpdir(), `uv-extract-${Date.now()}`); + + try { + mkdirSync(outDir, { recursive: true }); + mkdirSync(extractDir, { recursive: true }); + + console.log(`[download-uv] URL: ${url}`); + await downloadFile(url, tmpFile); + console.log(`[download-uv] Extracting to ${outDir}...`); + extract(tmpFile, extractDir, spec.ext); + + // Copy the required binaries to outDir + for (const bin of spec.binaries) { + const src = join(extractDir, bin); + const dest = join(outDir, bin); + + if (!existsSync(src)) { + throw new Error(`Expected binary not found after extraction: ${src}`); + } + + // copyFileSync works across filesystems (unlike renameSync with EXDEV) + copyFileSync(src, dest); + + if (process.platform !== "win32") { + chmodSync(dest, 0o755); + } + + console.log(`[download-uv] ✓ ${dest}`); + } + + console.log("[download-uv] Done. Binaries are ready for bundling."); + } finally { + // Clean up temp files (best-effort) + try { rmSync(tmpFile, { force: true }); } catch {} + try { rmSync(extractDir, { recursive: true, force: true }); } catch {} + } +} + +main().catch((err) => { + console.error("[download-uv] Error:", err.message); + process.exit(1); +}); diff --git a/scripts/gen-acp-docker-env.mjs b/scripts/gen-acp-docker-env.mjs new file mode 100644 index 0000000000000000000000000000000000000000..de1ecb0c85ca4f09535465c0cb67f8d5fc4e3bb9 --- /dev/null +++ b/scripts/gen-acp-docker-env.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Generate examples/acp-docker/.env from the single source of truth. + * + * Reads the version pins in config/defaults.json and writes the + * `AGENT_SERVER_IMAGE=` line into examples/acp-docker/.env, pinning the + * example to the exact `versions.agentServer` release. This keeps the + * reproducible quickstart path on the SoT version instead of a hardcoded + * tag that silently drifts below the Canvas compatibility floor + * (compatibility.minimumAgentServer) and renders "Disconnected". + * + * The no-config `docker compose up` path uses the compose fallback + * (`latest-python`, always >= the floor); running this script first pins the + * example to the reproducible SoT version instead. + * + * Idempotent: re-running upserts the AGENT_SERVER_IMAGE line and leaves any + * other lines in an existing .env untouched. + * + * Usage: + * node scripts/gen-acp-docker-env.mjs # or: npm run example:acp-docker:env + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +/** + * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config + * @returns {string} e.g. "ghcr.io/openhands/agent-server:1.28.1-python" + */ +export function computeAgentServerImage(config) { + return `${config.images.agentServer}:${config.versions.agentServer}-python`; +} + +/** + * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config + * @returns {string} the `AGENT_SERVER_IMAGE=` line + */ +export function renderEnvLine(config) { + return `AGENT_SERVER_IMAGE=${computeAgentServerImage(config)}`; +} + +/** + * Upsert the AGENT_SERVER_IMAGE line into an existing .env body, preserving + * every other line. Appends the line if absent. + * @param {string} existing prior .env contents ("" if the file is absent) + * @param {string} line the `AGENT_SERVER_IMAGE=...` line to set + * @returns {string} the updated .env contents + */ +export function upsertEnvLine(existing, line) { + const eq = line.indexOf("="); + if (eq <= 0) { + // A keyless line ("", "novalue", "=value") would make `key` empty and + // match every line — refuse rather than silently rewrite the whole file. + throw new Error( + `upsertEnvLine: expected a "KEY=value" line, got "${line}"`, + ); + } + const key = line.slice(0, eq + 1); // "AGENT_SERVER_IMAGE=" + const lines = existing.length ? existing.replace(/\n+$/, "").split("\n") : []; + let replaced = false; + const next = lines.map((l) => { + if (l.startsWith(key)) { + replaced = true; + return line; + } + return l; + }); + if (!replaced) next.push(line); + return next.join("\n") + "\n"; +} + +function loadConfig() { + return JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), + ); +} + +function main() { + const config = loadConfig(); + const line = renderEnvLine(config); + const envPath = join(projectRoot, "examples", "acp-docker", ".env"); + + let existing = ""; + try { + existing = readFileSync(envPath, "utf-8"); + } catch { + // no .env yet — create it + } + + const updated = upsertEnvLine(existing, line); + writeFileSync(envPath, updated); + console.log(`Wrote ${line} to examples/acp-docker/.env`); +} + +// Run main() only when invoked as a CLI. process.argv[1] is undefined in some +// ESM contexts (e.g. `node --input-type=module -e "import(...)"`), so guard it +// before pathToFileURL — otherwise importing this module for its exports throws +// ERR_INVALID_ARG_TYPE. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(); +} diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000000000000000000000000000000000000..fc059c370d5aae75a8ce7131dd07c6467e41f739 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,109 @@ +/** + * Generate electron/build-resources/icon.ico and icon.icns from the + * 1024×1024 icon.png master. Run after changing icon.png: + * + * npm run generate-icons + * + * The outputs are COMMITTED — electron-builder auto-discovers them in + * directories.buildResources and uses them as-is. We don't rely on + * electron-builder's own PNG→ICO conversion because it emits a single + * 256×256 PNG-compressed entry, which several Windows shell surfaces + * (Explorer small views, NSIS, taskbar) can't render — they fall back to + * the default Electron icon. png2icons with forWinExe=true stores the + * 48/32/24/16 entries as classic BMP, which is what Windows expects. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import png2icons from "png2icons"; + +// Standard Windows app-icon sizes (Microsoft recommends 16/24/32/48/256; +// 64/128 cover intermediate DPI scaling). +export const REQUIRED_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; + +// The ten standard macOS representations (16 → 512@2x). is32/il32 (+ the +// l8mk/s8mk masks png2icons emits alongside) are the legacy non-retina +// 16/32 forms; ic07–ic14 cover 128 → 512@2x. +export const REQUIRED_ICNS_TYPES = [ + "is32", + "il32", + "ic07", + "ic08", + "ic09", + "ic10", + "ic11", + "ic12", + "ic13", + "ic14", +]; + +/** + * Parse an ICO buffer's ICONDIR into [{size, isPng}] — isPng distinguishes + * PNG-compressed payloads from classic BMP entries. + */ +export function listIcoEntries(ico) { + const entries = []; + for (let i = 0; i < ico.readUInt16LE(4); i++) { + const entry = 6 + i * 16; + const width = ico[entry]; + const offset = ico.readUInt32LE(entry + 12); + entries.push({ + size: width === 0 ? 256 : width, + isPng: ico.readUInt32BE(offset) === 0x89504e47, // \x89PNG + }); + } + return entries; +} + +/** List the 4-char OSType of every chunk in an ICNS buffer. */ +export function listIcnsTypes(icns) { + const types = []; + for (let p = 8; p < icns.length; p += icns.readUInt32BE(p + 4)) { + types.push(icns.toString("ascii", p, p + 4)); + } + return types; +} + +/** + * Convert a PNG buffer into { ico, icns } buffers, asserting the emitted + * size sets so a png2icons upgrade that changes them fails loudly. + */ +export function generateIcons(input) { + // forWinExe: true → 48/32/24/16 stored as classic BMP entries (required + // by Windows shell small-icon surfaces), ≥64 PNG-compressed. 0 = lossless. + const ico = png2icons.createICO(input, png2icons.BICUBIC2, 0, false, true); + const icns = png2icons.createICNS(input, png2icons.BICUBIC2, 0); + if (!ico || !icns) throw new Error("png2icons failed to convert icon.png"); + + const icoSizes = listIcoEntries(ico).map((entry) => entry.size); + for (const size of REQUIRED_ICO_SIZES) { + if (!icoSizes.includes(size)) throw new Error(`icon.ico missing ${size}px`); + } + const icnsTypes = listIcnsTypes(icns); + for (const type of REQUIRED_ICNS_TYPES) { + if (!icnsTypes.includes(type)) throw new Error(`icon.icns missing ${type}`); + } + + return { ico, icns }; +} + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + const resDir = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "electron", + "build-resources", + ); + const { ico, icns } = generateIcons(readFileSync(join(resDir, "icon.png"))); + writeFileSync(join(resDir, "icon.ico"), ico); + writeFileSync(join(resDir, "icon.icns"), icns); + console.log( + `icon.ico (${listIcoEntries(ico) + .map((entry) => entry.size) + .join("/")}px, ${ico.length} B), ` + + `icon.icns (${listIcnsTypes(icns).join(",")}, ${icns.length} B)`, + ); +} diff --git a/scripts/ingress.mjs b/scripts/ingress.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1cf9a6c6d6d6ce8da9180439afaa10bca2312652 --- /dev/null +++ b/scripts/ingress.mjs @@ -0,0 +1,303 @@ +#!/usr/bin/env node +/** + * Standalone Ingress / Reverse Proxy + * + * A minimal HTTP reverse proxy that routes requests to multiple backends + * based on URL path. Completely independent of any backend implementation. + * + * Usage: + * node scripts/ingress.mjs [options] + * node scripts/ingress.mjs --port 8000 --route "/api/automation=http://localhost:18001" --route "/api=http://localhost:18000" --default "http://localhost:3001" + * + * Environment variables: + * INGRESS_PORT - Port to listen on (default: 8000) + * INGRESS_ROUTES - JSON object of path prefix -> backend URL + * INGRESS_DEFAULT - Default backend for unmatched routes + * INGRESS_RUNTIME_SERVICES_INFO - Runtime services JSON appended to + * /server_info + * + * Route matching: + * - Routes are matched by longest prefix first + * - More specific routes take precedence (e.g., /api/automation before /api) + */ + +import { createServer } from "node:http"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { + createProxyHandlers, + createRouter, + isBenignSocketError, + isServerInfoRequest, + matchesPathPrefix, + proxyServerInfoRequest, +} from "./proxy-utils.mjs"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Configuration +// ═══════════════════════════════════════════════════════════════════════════ + +function parseArgs() { + const args = process.argv.slice(2); + const config = { + port: 8000, + routes: {}, + defaultBackend: null, + noReferrerPrefixes: [], + runtimeServicesInfo: null, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "-p": + case "--port": + config.port = parseInt(args[++i], 10); + break; + case "-r": + case "--route": + // Format: "/path=http://host:port" + const [path, url] = args[++i].split("="); + config.routes[path] = url; + break; + case "-d": + case "--default": + config.defaultBackend = args[++i]; + break; + case "--no-referrer-prefix": { + const prefix = args[++i]; + if (!prefix || !prefix.startsWith("/")) { + throw new Error( + `--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`, + ); + } + config.noReferrerPrefixes.push(prefix); + break; + } + case "--runtime-services-info": + config.runtimeServicesInfo = args[++i] || null; + break; + case "-h": + case "--help": + showHelp(); + process.exit(0); + } + } + + return config; +} + +function showHelp() { + console.log(` +Standalone Ingress / Reverse Proxy + +Routes HTTP requests to multiple backends based on URL path prefix. + +USAGE: + node scripts/ingress.mjs [options] + +OPTIONS: + -p, --port Port to listen on (default: 8000) + -r, --route Add a route (can be repeated) + -d, --default Default backend for unmatched routes + --no-referrer-prefix

Send "Referrer-Policy: no-referrer" on proxied + responses under

. For upstreams whose URL + carries a credential in the query string. + --runtime-services-info Runtime services JSON for /server_info + -h, --help Show this help + +ENVIRONMENT VARIABLES: + INGRESS_PORT Port to listen on + INGRESS_ROUTES JSON object: {"path": "url", ...} + INGRESS_DEFAULT Default backend URL + INGRESS_RUNTIME_SERVICES_INFO + Runtime services JSON for /server_info + +EXAMPLES: + # Basic setup with agent server and automation + node scripts/ingress.mjs \\ + --port 8000 \\ + --route "/api/automation=http://localhost:18001" \\ + --route "/api=http://localhost:18000" \\ + --route "/sockets=http://localhost:18000" \\ + --default "http://localhost:3001" + + # Using environment variables + INGRESS_PORT=8000 \\ + INGRESS_ROUTES='{"/ api/automation":"http://localhost:18001","/api":"http://localhost:18000"}' \\ + INGRESS_DEFAULT="http://localhost:3001" \\ + node scripts/ingress.mjs + +ROUTE MATCHING: + Routes are sorted by path length (longest first), so more specific + routes like /api/automation will match before /api. +`); +} + +function buildConfig(args, env = process.env) { + let routes = { ...args.routes }; + + // Merge env routes + if (env.INGRESS_ROUTES) { + try { + const envRoutes = JSON.parse(env.INGRESS_ROUTES); + routes = { ...routes, ...envRoutes }; + } catch (e) { + console.error("Failed to parse INGRESS_ROUTES:", e.message); + } + } + + return { + port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000, + routes, + defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null, + noReferrerPrefixes: args.noReferrerPrefixes ?? [], + runtimeServicesInfo: + args.runtimeServicesInfo || env.INGRESS_RUNTIME_SERVICES_INFO || null, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Server +// ═══════════════════════════════════════════════════════════════════════════ + +export function startIngress(config) { + const route = createRouter(config.routes, config.defaultBackend); + const proxy = createProxyHandlers({ label: `ingress:${config.port}` }); + const uninstallDiagnostics = proxy.installDiagnostics(); + + const noReferrerPrefixes = config.noReferrerPrefixes ?? []; + + const server = createServer((req, res) => { + const url = req.url ?? "/"; + const backend = route(url); + + if (!backend) { + res.writeHead(503); + res.end("No backend configured for this route"); + return; + } + + // See the matching note in static-server.mjs: the editor's URL carries + // agent-server's session key as a query parameter, so the document must + // not send a Referer on the subresources the workbench loads. + if (noReferrerPrefixes.some((prefix) => matchesPathPrefix(url, prefix))) { + res.setHeader("Referrer-Policy", "no-referrer"); + } + + if ( + config.runtimeServicesInfo && + isServerInfoRequest(req) && + (req.method === "GET" || req.method === "HEAD") + ) { + proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); + return; + } + + proxy.proxyHttp(req, res, backend); + }); + + // Handle WebSocket upgrades + server.on("upgrade", (req, socket, head) => { + const backend = route(req.url ?? "/"); + + if (!backend) { + socket.destroy(); + return; + } + + proxy.proxyWebSocket(req, socket, head, backend); + }); + + // Built-in protection against malformed client requests that can otherwise + // bubble up as unhandled errors on the underlying TCP socket. + server.on("clientError", (err, socket) => { + if (!isBenignSocketError(err)) { + console.error("Client error:", err.message); + } + if (socket.writable) { + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); + } else { + socket.destroy(); + } + }); + server.on("close", uninstallDiagnostics); + + server.listen(config.port, () => { + console.log(""); + console.log( + "╔═══════════════════════════════════════════════════════════════╗", + ); + console.log( + "║ Ingress Proxy ║", + ); + console.log( + "╠═══════════════════════════════════════════════════════════════╣", + ); + console.log( + `║ Listening on: http://localhost:${config.port}/`.padEnd(66) + "║", + ); + console.log( + "╠═══════════════════════════════════════════════════════════════╣", + ); + console.log( + "║ Routes: ║", + ); + + const sortedRoutes = Object.entries(config.routes).sort( + ([a], [b]) => b.length - a.length, + ); + for (const [path, backend] of sortedRoutes) { + const line = ` ${path} → ${backend}`; + console.log(`║ ${line.padEnd(61)}║`); + } + + if (config.defaultBackend) { + const line = ` * (default) → ${config.defaultBackend}`; + console.log(`║ ${line.padEnd(61)}║`); + } + + console.log( + "║ ║", + ); + console.log( + "╚═══════════════════════════════════════════════════════════════╝", + ); + console.log(""); + }); + + return server; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Main +// ═══════════════════════════════════════════════════════════════════════════ + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + const args = parseArgs(); + const config = buildConfig(args); + + if (Object.keys(config.routes).length === 0 && !config.defaultBackend) { + console.error( + "Error: No routes configured. Use --route or --default options.", + ); + console.error("Run with --help for usage information."); + process.exit(1); + } + + startIngress(config); + + // Handle graceful shutdown + process.on("SIGINT", () => { + console.log("\nShutting down..."); + process.exit(0); + }); + + process.on("SIGTERM", () => { + console.log("\nShutting down..."); + process.exit(0); + }); +} diff --git a/scripts/logger.mjs b/scripts/logger.mjs new file mode 100644 index 0000000000000000000000000000000000000000..3710952f1a994ddab3d116d93e063aef17683c0f --- /dev/null +++ b/scripts/logger.mjs @@ -0,0 +1,106 @@ +/** + * Shared file logger for agent-canvas dev scripts. + * + * Writes log output to a daily-rotating file under + * /logs/agent-canvas.YYYY-MM-DD.log (7-day retention) alongside + * the existing console output (which is unchanged). + * + * winston is loaded dynamically and treated as optional: when it isn't + * resolvable — most importantly inside the packaged Electron desktop app, + * whose `afterPack` hook strips `Resources/app/node_modules/` — `fileLog` + * becomes a no-op and console logging continues to work unchanged. See + * AGENTS.md "Electron desktop packaging" for the strip-hook details. + */ + +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; + +// Mirror the state-directory logic from dev-safe.mjs so log files live +// alongside all other agent-canvas runtime state (e.g. ~/.openhands/agent-canvas). +// The same env var (OH_CANVAS_SAFE_STATE_DIR) overrides both. +const stateDir = + process.env.OH_CANVAS_SAFE_STATE_DIR || + join(homedir(), ".openhands", "agent-canvas"); +const logDir = join(stateDir, "logs"); + +// Matches any ANSI CSI escape sequence (colors, cursor movement, etc.). +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +/** + * Remove ANSI escape codes so log files contain clean plain text. + * @param {string} str + * @returns {string} + */ +export function stripAnsi(str) { + return typeof str === "string" ? str.replace(ANSI_RE, "") : String(str); +} + +/** + * Attempt to construct a winston-backed file logger. Returns `null` if + * winston isn't installed (packaged desktop app) or any setup step fails; + * `fileLog` then degrades to a no-op. + * + * @returns {Promise} + */ +async function createFileLogger() { + let winston; + let DailyRotateFileMod; + try { + winston = await import("winston"); + DailyRotateFileMod = await import("winston-daily-rotate-file"); + } catch { + return null; + } + + try { + mkdirSync(logDir, { recursive: true }); + + const DailyRotateFile = + DailyRotateFileMod.default ?? DailyRotateFileMod; + const fileTransport = new DailyRotateFile({ + dirname: logDir, + filename: "agent-canvas.%DATE%.log", + datePattern: "YYYY-MM-DD", + maxFiles: "7d", + auditFile: join(logDir, ".log-audit.json"), + createSymlink: false, + }); + + const logger = winston.createLogger({ + level: "debug", + format: winston.format.combine( + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.printf( + ({ timestamp, level, message }) => + `${timestamp} [${level.toUpperCase().padEnd(5)}] ${message}`, + ), + ), + transports: [fileTransport], + }); + + // Swallow any transport-level errors (e.g. disk full) so a logging + // failure never crashes the dev server. + logger.on("error", () => {}); + fileTransport.on("error", () => {}); + return logger; + } catch { + return null; + } +} + +const fileLogger = await createFileLogger(); + +/** + * Write a message to the rotating log file. No-ops when winston isn't + * available (e.g. the packaged desktop app). ANSI escape codes are + * stripped automatically; console output is unaffected. + * + * @param {'info' | 'warn' | 'error' | 'debug'} level + * @param {string} message + */ +export function fileLog(level, message) { + if (!fileLogger) return; + fileLogger.log(level, stripAnsi(message)); +} diff --git a/scripts/make-i18n-translations.cjs b/scripts/make-i18n-translations.cjs new file mode 100644 index 0000000000000000000000000000000000000000..cefd1c9cb2502b69ed7bc1696262dafa21372278 --- /dev/null +++ b/scripts/make-i18n-translations.cjs @@ -0,0 +1,53 @@ +const fs = require("fs"); +const path = require("path"); +const i18n = require("../src/i18n/translation.json"); + +const namespace = "openhands"; + +// { [lang]: { [key]: content } } +const translationMap = {}; + +Object.entries(i18n).forEach(([key, transMap]) => { + Object.entries(transMap).forEach(([lang, content]) => { + if (!translationMap[lang]) { + translationMap[lang] = {}; + } + translationMap[lang][key] = content; + }); +}); + +// remove old locales directory +const localesPath = path.join(__dirname, "../public/locales"); +if (fs.existsSync(localesPath)) { + fs.rmSync(localesPath, { recursive: true }); +} + +// write translation files +Object.entries(translationMap).forEach(([lang, transMap]) => { + const filePath = path.join( + __dirname, + `../public/locales/${lang}/${namespace}.json`, + ); + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(transMap, null, 2)); +}); + +// write translation key enum +const transKeys = Object.keys(translationMap.en); +const transKeyDeclareFilePath = path.join( + __dirname, + "../src/i18n/declaration.ts", +); +if (!fs.existsSync(transKeyDeclareFilePath)) { + fs.mkdirSync(path.dirname(transKeyDeclareFilePath), { recursive: true }); +} +fs.writeFileSync( + transKeyDeclareFilePath, + ` +// this file generate by script, don't modify it manually!!! +export enum I18nKey { +${transKeys.map((key) => ` ${key} = "${key}",`).join("\n")} +}`.trim() + "\n", +); diff --git a/scripts/proxy-utils.mjs b/scripts/proxy-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4d81dd870fd8d4fb048fc9a7203eba5b932d84d1 --- /dev/null +++ b/scripts/proxy-utils.mjs @@ -0,0 +1,326 @@ +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { createProxyServer } from "httpxy"; + +const DEFAULT_PROXY_TIMEOUT_MS = 120_000; +const SERVER_INFO_PATH = "/server_info"; +const BENIGN_SOCKET_ERRORS = new Set([ + "ECONNRESET", + "EPIPE", + "ECONNABORTED", + "ERR_STREAM_PREMATURE_CLOSE", +]); + +export function matchesPathPrefix(url, prefix) { + return ( + url === prefix || + url.startsWith(prefix + "/") || + url.startsWith(prefix + "?") + ); +} + +export function createRouter(routes, defaultBackend = null) { + const sortedRoutes = Object.entries(routes).sort( + ([a], [b]) => b.length - a.length, + ); + + return function route(url) { + for (const [prefix, backend] of sortedRoutes) { + if (matchesPathPrefix(url, prefix)) { + return backend; + } + } + return defaultBackend; + }; +} + +export function isBenignSocketError(err) { + return Boolean(err && BENIGN_SOCKET_ERRORS.has(err.code)); +} + +function parseBackendUrl(backendUrl) { + const url = new URL(backendUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Invalid backend URL"); + } + return { + hostname: url.hostname, + port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), + protocol: url.protocol, + }; +} + +function writeInvalidBackendUrlResponse(req, res) { + const message = "Invalid backend URL"; + console.error(`Proxy error for ${req.url}: ${message}`); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`Bad Gateway: ${message}`); + } else { + res.destroy(); + } +} + +export function isServerInfoRequest(req) { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + return pathname === SERVER_INFO_PATH; +} + +export function proxyServerInfoRequest( + req, + res, + backendUrl, + runtimeServicesInfo, +) { + let backend; + try { + backend = parseBackendUrl(backendUrl); + } catch { + writeInvalidBackendUrlResponse(req, res); + return; + } + + const request = backend.protocol === "https:" ? httpsRequest : httpRequest; + const proxyReq = request( + { + hostname: backend.hostname, + port: backend.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: `${backend.hostname}:${backend.port}`, + }, + }, + (proxyRes) => { + const chunks = []; + + proxyRes.on("data", (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + + proxyRes.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Upstream response error for ${req.url}:`, err.message); + } + if (!res.headersSent) { + res.writeHead(502); + res.end(`Bad Gateway: ${err.message}`); + } else { + res.destroy(); + } + }); + + proxyRes.on("end", () => { + const statusCode = proxyRes.statusCode ?? 502; + const headers = { ...proxyRes.headers }; + const originalBody = Buffer.concat(chunks); + + if (statusCode < 200 || statusCode >= 300 || req.method === "HEAD") { + res.writeHead(statusCode, headers); + res.end(req.method === "HEAD" ? "" : originalBody); + return; + } + + try { + const serverInfo = JSON.parse(originalBody.toString("utf8")); + const runtimeServices = + typeof runtimeServicesInfo === "string" + ? JSON.parse(runtimeServicesInfo) + : runtimeServicesInfo; + const body = Buffer.from( + JSON.stringify({ + ...serverInfo, + runtime_services: runtimeServices, + }), + "utf8", + ); + + delete headers["content-length"]; + delete headers["transfer-encoding"]; + headers["content-type"] = "application/json; charset=utf-8"; + headers["cache-control"] = "no-store"; + res.writeHead(statusCode, headers); + res.end(body); + } catch (err) { + console.warn( + `Could not append runtime_services to ${SERVER_INFO_PATH}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.writeHead(statusCode, headers); + res.end(originalBody); + } + }); + }, + ); + + proxyReq.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Proxy error for ${req.url}:`, err.message); + } + if (!res.headersSent) { + res.writeHead(502); + res.end(`Bad Gateway: ${err.message}`); + } else { + res.destroy(); + } + }); + + req.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Client request error for ${req.url}:`, err.message); + } + proxyReq.destroy(); + }); + + res.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Client response error for ${req.url}:`, err.message); + } + proxyReq.destroy(); + }); + + req.pipe(proxyReq, { end: true }); +} + +function once(fn) { + let called = false; + return (...args) => { + if (called) return; + called = true; + fn(...args); + }; +} + +function writeProxyError(res, message) { + if (res.destroyed) return; + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`Bad Gateway: ${message}`); + return; + } + res.destroy(); +} + +export function createProxyHandlers({ + label = "proxy", + timeout = DEFAULT_PROXY_TIMEOUT_MS, + proxyTimeout = DEFAULT_PROXY_TIMEOUT_MS, +} = {}) { + const proxy = createProxyServer({ + ws: true, + changeOrigin: true, + xfwd: true, + timeout, + proxyTimeout, + }); + const metrics = { + activeHttpRequests: 0, + activeWebSockets: 0, + totalHttpRequests: 0, + totalWebSockets: 0, + totalErrors: 0, + }; + + proxy.on("error", (err, _req, resOrSocket, target) => { + metrics.totalErrors += 1; + const targetText = target ? ` -> ${target}` : ""; + if (!isBenignSocketError(err)) { + console.error(`[${label}] Proxy error${targetText}: ${err.message}`); + } + if (resOrSocket && typeof resOrSocket.writeHead === "function") { + writeProxyError(resOrSocket, err.message); + } else if (resOrSocket && typeof resOrSocket.destroy === "function") { + resOrSocket.destroy(); + } + }); + + function proxyHttp(req, res, target) { + metrics.activeHttpRequests += 1; + metrics.totalHttpRequests += 1; + const finish = once(() => { + metrics.activeHttpRequests = Math.max(0, metrics.activeHttpRequests - 1); + }); + res.on("close", finish); + res.on("finish", finish); + res.on("error", finish); + + const handleProxyError = (err) => { + metrics.totalErrors += 1; + if (!isBenignSocketError(err)) { + console.error( + `[${label}] Proxy error for ${req.url} -> ${target}:`, + err, + ); + } + writeProxyError(res, err instanceof Error ? err.message : String(err)); + finish(); + }; + + try { + proxy.web(req, res, { target }).catch(handleProxyError); + } catch (err) { + handleProxyError(err); + } + } + + function proxyWebSocket(req, socket, head, target) { + metrics.activeWebSockets += 1; + metrics.totalWebSockets += 1; + const finish = once(() => { + metrics.activeWebSockets = Math.max(0, metrics.activeWebSockets - 1); + }); + socket.on("close", finish); + socket.on("error", finish); + + try { + proxy.ws(req, socket, { target }, head).catch((err) => { + metrics.totalErrors += 1; + if (!isBenignSocketError(err)) { + console.error( + `[${label}] WebSocket proxy error for ${req.url} -> ${target}:`, + err, + ); + } + socket.destroy(); + finish(); + }); + } catch (err) { + metrics.totalErrors += 1; + if (!isBenignSocketError(err)) { + console.error( + `[${label}] WebSocket proxy error for ${req.url} -> ${target}:`, + err, + ); + } + socket.destroy(); + finish(); + } + } + + function dumpMetrics() { + console.log( + `[${label}] active_http=${metrics.activeHttpRequests} ` + + `active_ws=${metrics.activeWebSockets} ` + + `total_http=${metrics.totalHttpRequests} ` + + `total_ws=${metrics.totalWebSockets} ` + + `total_errors=${metrics.totalErrors}`, + ); + } + + function installDiagnostics(signal = "SIGUSR1") { + process.on(signal, dumpMetrics); + return () => { + process.off(signal, dumpMetrics); + }; + } + + return { + proxyHttp, + proxyWebSocket, + dumpMetrics, + installDiagnostics, + metrics, + }; +} diff --git a/scripts/run-staged-typecheck.mjs b/scripts/run-staged-typecheck.mjs new file mode 100644 index 0000000000000000000000000000000000000000..96baef01ea6941e07e79d8e00845812dabe6ccdf --- /dev/null +++ b/scripts/run-staged-typecheck.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +function getNpmInvocation() { + if (process.platform !== "win32") { + return { command: "npm", args: ["run", "typecheck:staged"] }; + } + + const npmCliPath = + process.env.npm_execpath ?? + join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); + + if (existsSync(npmCliPath)) { + return { + command: process.execPath, + args: [npmCliPath, "run", "typecheck:staged"], + }; + } + + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", "npm run typecheck:staged"], + }; +} + +const { command, args } = getNpmInvocation(); + +const result = spawnSync(command, args, { + stdio: "inherit", + windowsHide: true, +}); + +if (result.error) { + throw result.error; +} + +if (result.signal) { + console.error(`typecheck:staged terminated by signal ${result.signal}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/runtime-services-info.mjs b/scripts/runtime-services-info.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0519076637424ab95532bbb4e2bfcede688b2e5b --- /dev/null +++ b/scripts/runtime-services-info.mjs @@ -0,0 +1,199 @@ +/** + * Single source of truth for the `` block. + * + * Builds a structured description of the services that are reachable from + * inside the agent's sandbox. Agent Canvas backend-serving processes attach it + * to `/server_info.runtime_services`; the frontend renders that backend value + * into `AgentContext.system_message_suffix`, so the agent sees a + * `` block listing what's available without having to probe. + * + * Two callers share this one definition: + * - the dev launchers (scripts/dev-*.mjs), which know the stack as a set of + * ports and pass the result to ingress/static-server for `/server_info`; + * - docker/entrypoint.sh, which runs this file as a CLI (see the bottom of + * this module) because in a container the URLs are *runtime* config — the + * ports and base URLs are overridable at `docker run` and therefore cannot + * be baked into the image at build time. The JSON it prints is passed to + * scripts/static-server.mjs and exposed through `/server_info`. + * + * URLs are written from the *agent's* point of view (i.e. as the agent should + * curl/fetch them from inside its sandbox), which is deliberately not the + * browser's point of view. + */ + +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +/** + * @param {object} options + * @param {string} [options.mode] - Human-readable mode label (e.g. "dev:safe"). + * @param {string} [options.agentHostAlias="localhost"] - Hostname the agent + * uses to reach host-side services (ingress, frontend, port-derived + * automation). Also surfaced as `agent_host_alias`. + * @param {number} [options.agentServerPort] - Port the agent-server listens on. + * Used to derive the agent_server URL when `agentServerUrl` is not given. + * @param {string} [options.agentServerUrl] - Explicit agent_server URL, from + * the agent's POV. Takes precedence over `agentServerPort`; used by the + * Docker image, which serves over `127.0.0.1` to avoid IPv6 loopback issues + * and honors an overridable `AGENT_SERVER_URL`. One of `agentServerUrl` / + * `agentServerPort` is required (else the URL would be `:undefined`). + * @param {number} [options.ingressPort] - Ingress port (omit if no ingress). + * @param {number} [options.frontendPort] - Frontend port (Vite dev server + * or static-file server). Omit if no frontend is exposed. + * @param {number} [options.vitePort] - Deprecated alias for `frontendPort`, + * accepted for backward compat with older launchers. Remove after one release. + * @param {"vite"|"static"} [options.frontendKind="vite"] - Whether the + * frontend port hosts Vite or a static build. Only affects the description. + * @param {object} [options.automation] - Automation backend info. Skipped + * entirely unless `.url` or `.port` is provided, so passing `{}` is safe. + * @param {string} [options.automation.url] - Explicit automation base URL, from + * the agent's POV. Takes precedence over `.port`; used by the Docker image to + * honor an overridable `AUTOMATION_BASE_URL`. + * @param {number} [options.automation.port] - Automation backend port (used to + * derive the base URL when `.url` is not given). + * @param {string} [options.automation.apiPrefix="/api/automation"] - Path + * prefix all automation routes are mounted under. + * @param {string} [options.automation.authEnvVar="OPENHANDS_AUTOMATION_API_KEY"] + * - Env var holding the API key. + * @returns {object} A JSON-serializable runtime services info object. + */ +export function buildRuntimeServicesInfo(options) { + const { + mode, + agentHostAlias = "localhost", + agentServerPort, + agentServerUrl, + ingressPort, + // Accept legacy `vitePort` for one release so external callers keep working. + vitePort, + frontendPort = vitePort, + frontendKind = "vite", + automation, + } = options; + + // Prefer an explicit URL (containers reach the agent-server over a specific + // host/scheme), else derive it from the port. From the agent's POV the + // agent-server it's *inside* is on the loopback host, regardless of where + // the host machine is. + const agentServerUrlResolved = + agentServerUrl ?? + (agentServerPort != null ? `http://localhost:${agentServerPort}` : null); + if (!agentServerUrlResolved) { + // Without this the URL becomes `http://localhost:undefined` and ends up + // verbatim in the agent's system prompt, which is worse than failing fast. + throw new Error( + "buildRuntimeServicesInfo: agentServerPort or agentServerUrl is required " + + "(otherwise the agent_server URL would be `http://localhost:undefined`).", + ); + } + + const services = { + agent_server: { + description: + "The OpenHands Agent Server this agent is running inside. " + + "Tool calls (terminal, file_editor, browser, etc.) execute here.", + url_from_agent: agentServerUrlResolved, + }, + }; + + if (ingressPort !== undefined) { + services.ingress = { + description: + "Unified entry point. Routes /api/automation/* to the automation " + + "backend, /api/* and /sockets to the agent-server, and /* to the " + + "frontend.", + url_from_agent: `http://${agentHostAlias}:${ingressPort}`, + }; + } + + if (frontendPort !== undefined) { + services.frontend = { + kind: frontendKind, + description: + frontendKind === "static" + ? "Static-file server hosting the agent-canvas production build." + : "Vite dev server hosting the agent-canvas frontend.", + url_from_agent: `http://${agentHostAlias}:${frontendPort}`, + }; + } + + // Prefer an explicit base URL, else derive from the port. Require one of the + // two so we don't bake `:undefined` into the URL when the caller passes + // `automation: {}`. + const automationBaseUrl = + automation?.url ?? + (automation?.port != null + ? `http://${agentHostAlias}:${automation.port}` + : null); + if (automationBaseUrl) { + const apiPrefix = automation.apiPrefix ?? "/api/automation"; + const authEnvVar = automation.authEnvVar ?? "OPENHANDS_AUTOMATION_API_KEY"; + services.automation = { + description: + "OpenHands Automations service. All routes are mounted under " + + `'${apiPrefix}'. Authenticate with header ` + + `'X-Session-API-Key: $${authEnvVar}'.`, + url_from_agent: automationBaseUrl, + api_prefix: apiPrefix, + docs_url: `${automationBaseUrl}${apiPrefix}/docs`, + openapi_url: `${automationBaseUrl}${apiPrefix}/openapi.json`, + auth_env_var: authEnvVar, + }; + } + + return { + mode, + agent_host_alias: agentHostAlias, + services, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CLI — used by docker/entrypoint.sh to emit the JSON at container startup. +// ───────────────────────────────────────────────────────────────────────────── + +export function parseArgs(argv) { + const options = { automation: {} }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + switch (flag) { + case "--mode": + options.mode = argv[++i]; + break; + case "--agent-host-alias": + options.agentHostAlias = argv[++i]; + break; + case "--agent-server-url": + options.agentServerUrl = argv[++i] || undefined; + break; + case "--automation-url": + options.automation.url = argv[++i] || undefined; + break; + case "--automation-api-prefix": + options.automation.apiPrefix = argv[++i]; + break; + case "--automation-auth-env": + options.automation.authEnvVar = argv[++i]; + break; + default: + throw new Error(`Unknown flag: ${flag}`); + } + } + // Omit the automation entry entirely when no URL was supplied, rather than + // advertising a backend the agent cannot reach. + if (!options.automation.url) delete options.automation; + return options; +} + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + try { + const options = parseArgs(process.argv.slice(2)); + process.stdout.write(JSON.stringify(buildRuntimeServicesInfo(options))); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/scripts/seed-automation-ux-data.mjs b/scripts/seed-automation-ux-data.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a08e7ac835afa542df56ba4bbae3686452037f18 --- /dev/null +++ b/scripts/seed-automation-ux-data.mjs @@ -0,0 +1,810 @@ +#!/usr/bin/env node +/** + * Seed local automation UX data (list, filters, detail, run history). + * + * Usage: + * node scripts/seed-automation-ux-data.mjs + * + * Env: + * AUTOMATION_BASE_URL Ingress origin (default http://localhost:8100) + * SESSION_API_KEY X-Session-API-Key (default ~/.openhands/agent-canvas/api-key.txt) + * AUTOMATION_DB SQLite path (default .tmp/automation/automations.db) + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +const BASE_URL = ( + process.env.AUTOMATION_BASE_URL || "http://localhost:8100" +).replace(/\/$/, ""); +const DB_PATH = + process.env.AUTOMATION_DB || + join(repoRoot, ".tmp/automation/automations.db"); +const API_KEY = + process.env.SESSION_API_KEY || + readFileSync( + join(homedir(), ".openhands/agent-canvas/api-key.txt"), + "utf8", + ).trim(); + +const hoursAgo = (hours) => new Date(Date.now() - hours * 3_600_000); + +const SEEDS = [ + { + name: "PR Triage Digest", + prompt: + "Review newly opened pull requests in acme/frontend-app, identify risky changes, summarize likely impact, and prepare a concise digest with priority ordering for the engineering review channel.", + model: "triage-fast", + timeout: 600, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "cron", + schedule: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + }, + scheduleHuman: "Weekdays at 09:00", + lastTriggeredHoursAgo: 2, + runs: [ + ["COMPLETED", 2, 0.42], + ["COMPLETED", 26, 0.38], + ["FAILED", 50, 0.11], + ["COMPLETED", 74, 0.41], + ["COMPLETED", 98, 0.36], + ["COMPLETED", 170, 0.4], + ["FAILED", 194, 0.09], + ["COMPLETED", 218, 0.37], + ["COMPLETED", 242, 0.39], + ["COMPLETED", 266, 0.35], + ], + }, + { + name: "Nightly Security Pass", + prompt: + "Scan the acme/backend-api repository for known security vulnerabilities, outdated dependencies, and insecure code patterns. Produce a prioritized remediation summary.", + model: "security-careful", + timeout: 900, + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "30 1 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 01:30", + lastTriggeredHoursAgo: 8, + runs: [ + ["COMPLETED", 8, 1.12], + ["COMPLETED", 32, 1.05], + ["COMPLETED", 56, 0.98], + ["FAILED", 80, 0.22], + ["COMPLETED", 104, 1.08], + ], + }, + { + name: "Docs Sync on Push", + prompt: + "Monitor acme/docs for new pushes. For each push, generate a changelog-ready summary of what changed and why.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "*/15 * * * *", timezone: "America/New_York" }, + scheduleHuman: "Every 15 minutes", + lastTriggeredHoursAgo: 1, + runs: [ + ["COMPLETED", 1, 0.08], + ["CANCELLED", 18, null], + ["SKIPPED", 42, null], + ], + }, + { + name: "Release Readiness Review", + prompt: + "Compile a release readiness report: list open blockers, active incidents, and pending approvals for acme/realtime-service.", + model: "release-review", + enabled: false, + repos: [{ url: "acme/realtime-service", ref: "release", provider: "github" }], + trigger: { type: "cron", schedule: "0 11 * * 5", timezone: "America/Chicago" }, + scheduleHuman: "Fridays at 11:00", + lastTriggeredHoursAgo: 14 * 24, + runs: [ + ["FAILED", 14 * 24, null], + ["COMPLETED", 21 * 24, 0.67], + ], + }, + { + name: "Incident Webhook Summary", + prompt: + "Summarize incoming incident webhooks, categorize by severity, and post a digest to the on-call Slack channel.", + model: "incident-summary", + enabled: false, + repos: [{ url: "acme/incident-service", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 */2 * * *", timezone: "UTC" }, + scheduleHuman: "Every 2 hours", + lastTriggeredHoursAgo: null, + runs: [], + }, + { + name: "PR Review on Open", + prompt: + "When a new PR is opened, perform a thorough code review focusing on correctness, security, and performance. Post findings as inline comments.", + model: "review-fast", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request.opened", + filter: "repository.full_name == 'acme/frontend-app'", + }, + lastTriggeredHoursAgo: 3, + runs: [ + ["COMPLETED", 3, 0.88], + ["COMPLETED", 6, 0.79], + ["FAILED", 20, 0.15], + ["COMPLETED", 44, 0.81], + ["COMPLETED", 68, 0.74], + ], + }, + { + name: "Release Notes Generator", + prompt: + "Generate comprehensive release notes from the commits since the last release. Include breaking changes, new features, and bug fixes.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "release.published", + filter: "glob(release.tag_name, 'v*') && !release.prerelease", + }, + lastTriggeredHoursAgo: 72, + runs: [ + ["COMPLETED", 72, 0.54], + ["COMPLETED", 240, 0.61], + ], + }, + { + name: "Weekly Standup Digest", + prompt: + "Collect merged PRs, open incidents, and Linear tickets moved this week. Draft a standup digest for #eng-standup.", + model: "standup-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 9 * * 1", timezone: "America/Los_Angeles" }, + scheduleHuman: "Mondays at 09:00", + lastTriggeredHoursAgo: 0.25, + running: true, + runs: [ + ["COMPLETED", 168, 0.29], + ["COMPLETED", 336, 0.31], + ["COMPLETED", 504, 0.27], + ], + }, + { + name: "Slack Channel Monitor", + prompt: + "Watch #support for customer-reported regressions. When a thread looks like a product bug, file a Linear issue and link the Slack thread.", + model: "support-fast", + enabled: true, + trigger: { + type: "event", + source: "slack", + on: "message.channels", + filter: "icontains(text, 'bug') || icontains(text, 'broken')", + }, + lastTriggeredHoursAgo: 5, + runs: [ + ["COMPLETED", 5, 0.19], + ["COMPLETED", 12, 0.16], + ["SKIPPED", 29, null], + ["COMPLETED", 53, 0.21], + ], + }, + { + name: "Dependabot Triage", + prompt: + "Review Dependabot PRs, group safe version bumps, and flag breaking major upgrades that need a human owner.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 8 * * 1-5", timezone: "UTC" }, + scheduleHuman: "Weekdays at 08:00", + lastTriggeredHoursAgo: 4, + runs: [ + ["FAILED", 4, 0.07], + ["FAILED", 28, 0.06], + ["COMPLETED", 52, 0.33], + ["FAILED", 76, 0.05], + ], + }, + { + name: "Stale PR Nudge", + prompt: + "Find pull requests in acme/frontend-app that have had no review activity for 5 days. Post a polite nudge on the PR and summarize owners in #eng-reviews.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 10 * * 1-5", timezone: "America/Los_Angeles" }, + scheduleHuman: "Weekdays at 10:00", + lastTriggeredHoursAgo: 6, + runs: [ + ["COMPLETED", 6, 0.14], + ["COMPLETED", 30, 0.12], + ["COMPLETED", 54, 0.16], + ["SKIPPED", 78, null], + ], + }, + { + name: "Flaky Test Hunter", + prompt: + "Analyze the last 48 hours of CI on acme/frontend-app. Identify flaky tests, group by file, and open or update a tracking issue with reproduction hints.", + model: "careful", + timeout: 1200, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 7 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 07:00", + lastTriggeredHoursAgo: 9, + runs: [ + ["COMPLETED", 9, 1.44], + ["FAILED", 33, 0.28], + ["COMPLETED", 57, 1.31], + ["COMPLETED", 81, 1.22], + ], + }, + { + name: "License Compliance Sweep", + prompt: + "Scan acme/backend-api dependencies for GPL or unknown licenses. Produce a table of new findings since the last run.", + model: "security-careful", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 3 * * 1", timezone: "UTC" }, + scheduleHuman: "Mondays at 03:00", + lastTriggeredHoursAgo: 20, + runs: [ + ["COMPLETED", 20, 0.77], + ["COMPLETED", 188, 0.81], + ], + }, + { + name: "Changelog Drafter", + prompt: + "Draft this week's changelog for acme/docs from merged PRs labeled feature, fix, or breaking.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 16 * * 5", timezone: "America/New_York" }, + scheduleHuman: "Fridays at 16:00", + lastTriggeredHoursAgo: 48, + runs: [ + ["COMPLETED", 48, 0.24], + ["COMPLETED", 216, 0.22], + ["CANCELLED", 384, null], + ], + }, + { + name: "Broken Link Checker", + prompt: + "Crawl published docs in acme/docs, report broken internal and external links, and file issues for anything older than 7 days.", + model: "docs-fast", + enabled: false, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 4 * * 0", timezone: "UTC" }, + scheduleHuman: "Sundays at 04:00", + lastTriggeredHoursAgo: 36, + runs: [ + ["FAILED", 36, null], + ["COMPLETED", 204, 0.45], + ], + }, + { + name: "Onboarding Buddy", + prompt: + "When a new engineer is added to the org, generate a first-week checklist from acme/handbook and post it to their Slack DM.", + model: "standup-fast", + enabled: true, + repos: [{ url: "acme/handbook", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "membership.added", + filter: "team.name == 'engineering'", + }, + lastTriggeredHoursAgo: 96, + runs: [ + ["COMPLETED", 96, 0.18], + ], + }, + { + name: "Issue to Draft PR", + prompt: + "When a Linear issue is labeled 'ready-for-agent', create a draft PR in the linked repo with a first-pass implementation and a test plan.", + model: "careful", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "linear", + on: "issue.updated", + filter: "contains(labels, 'ready-for-agent')", + }, + lastTriggeredHoursAgo: 11, + running: true, + runs: [ + ["COMPLETED", 35, 2.18], + ["FAILED", 59, 0.41], + ["COMPLETED", 110, 1.96], + ], + }, + { + name: "CI Failure Autopsy", + prompt: + "When a GitHub check suite fails on main, summarize the failing jobs, likely root cause, and whether this looks flaky or a real regression.", + model: "careful", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "check_suite.completed", + filter: "check_suite.conclusion == 'failure' && check_suite.head_branch == 'main'", + }, + lastTriggeredHoursAgo: 1.5, + runs: [ + ["COMPLETED", 1.5, 0.52], + ["COMPLETED", 7, 0.48], + ["FAILED", 14, 0.19], + ["COMPLETED", 22, 0.55], + ["COMPLETED", 31, 0.47], + ], + }, + { + name: "Push Changelog Ping", + prompt: + "On every push to main in acme/docs, post a 3-bullet summary to #docs-updates.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "push", + filter: "ref == 'refs/heads/main'", + }, + lastTriggeredHoursAgo: 0.8, + runs: [ + ["COMPLETED", 0.8, 0.06], + ["COMPLETED", 3.2, 0.05], + ["SKIPPED", 5, null], + ["COMPLETED", 9, 0.07], + ["COMPLETED", 14, 0.06], + ], + }, + { + name: "Review Comment Resolver", + prompt: + "When a reviewer leaves a comment containing '@openhands please fix', apply the requested change and reply with a summary of the edit.", + model: "review-fast", + timeout: 1500, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request_review_comment.created", + filter: "icontains(comment.body, '@openhands please fix')", + }, + lastTriggeredHoursAgo: 7, + runs: [ + ["COMPLETED", 7, 0.63], + ["COMPLETED", 19, 0.71], + ["CANCELLED", 27, null], + ], + }, + { + name: "Jira Bug to Repro", + prompt: + "When a Jira bug is moved to Ready, clone the linked repo, write a failing reproduction test, and attach the patch to the ticket.", + model: "careful", + enabled: false, + repos: [{ url: "acme/realtime-service", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "jira", + on: "issue.updated", + filter: "fields.status.name == 'Ready' && fields.issuetype.name == 'Bug'", + }, + lastTriggeredHoursAgo: 60, + runs: [ + ["FAILED", 60, 0.33], + ], + }, + { + name: "Monthly Cost Report", + prompt: + "Summarize last month's LLM spend by automation, highlight outliers, and recommend timeouts or model changes.", + model: "standup-fast", + enabled: true, + trigger: { type: "cron", schedule: "0 9 1 * *", timezone: "America/Los_Angeles" }, + scheduleHuman: "1st of the month at 09:00", + lastTriggeredHoursAgo: 240, + runs: [ + ["COMPLETED", 240, 0.31], + ["COMPLETED", 960, 0.28], + ], + }, + { + name: "Weekend On-call Brief", + prompt: + "Friday afternoon: compile open Sev-1/Sev-2 incidents, recent deploys, and a rollback cheat sheet for the weekend on-call.", + model: "incident-summary", + enabled: true, + repos: [{ url: "acme/incident-service", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 16 * * 5", timezone: "America/Los_Angeles" }, + scheduleHuman: "Fridays at 16:00", + lastTriggeredHoursAgo: 50, + runs: [ + ["COMPLETED", 50, 0.39], + ["COMPLETED", 218, 0.41], + ["COMPLETED", 386, 0.36], + ], + }, + { + name: "i18n Drift Check", + prompt: + "Compare src/i18n/translation.json keys against English source strings. List missing translations and unused keys.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "30 6 * * 1-5", timezone: "UTC" }, + scheduleHuman: "Weekdays at 06:30", + lastTriggeredHoursAgo: 12, + runs: [ + ["COMPLETED", 12, 0.17], + ["COMPLETED", 36, 0.15], + ["FAILED", 60, 0.04], + ["COMPLETED", 84, 0.16], + ], + }, + { + name: "Coverage Gate Watcher", + prompt: + "If a PR drops line coverage by more than 1%, comment with the files responsible and suggested tests.", + model: "review-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request.synchronize", + filter: "repository.full_name == 'acme/backend-api'", + }, + lastTriggeredHoursAgo: 4.5, + runs: [ + ["COMPLETED", 4.5, 0.27], + ["SKIPPED", 8, null], + ["COMPLETED", 16, 0.29], + ["COMPLETED", 28, 0.25], + ], + }, + { + name: "Draft PR Reminder", + prompt: + "Find draft PRs older than 10 days. Ask the author if they still intend to ship, and close with a comment if they agree.", + model: "triage-fast", + enabled: false, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 12 * * 3", timezone: "America/Los_Angeles" }, + scheduleHuman: "Wednesdays at 12:00", + lastTriggeredHoursAgo: null, + runs: [], + }, + { + name: "Design Token Audit", + prompt: + "Scan acme/frontend-app for hardcoded hex colors and spacing values that should use design tokens. Group by file and suggest replacements.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 5 * * 2", timezone: "UTC" }, + scheduleHuman: "Tuesdays at 05:00", + lastTriggeredHoursAgo: 70, + runs: [ + ["COMPLETED", 70, 0.58], + ["COMPLETED", 238, 0.62], + ], + }, + { + name: "Sentry Spike Explainer", + prompt: + "When Sentry error volume spikes 3x hour-over-hour, explain the top stack traces and whether a recent deploy is implicated.", + model: "incident-summary", + enabled: true, + trigger: { + type: "event", + source: "sentry", + on: "metric.alert", + filter: "alert.name == 'error-volume-spike'", + }, + lastTriggeredHoursAgo: 15, + runs: [ + ["COMPLETED", 15, 0.44], + ["FAILED", 40, 0.12], + ["COMPLETED", 90, 0.49], + ], + }, + { + name: "GitLab MR Review", + prompt: + "Review newly opened merge requests in acme/data-platform. Focus on SQL safety, partition filters, and cost of full-table scans.", + model: "review-fast", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/data-platform", ref: "main", provider: "gitlab" }], + trigger: { + type: "event", + source: "gitlab", + on: "merge_request.opened", + }, + lastTriggeredHoursAgo: 18, + runs: [ + ["COMPLETED", 18, 0.91], + ["COMPLETED", 41, 0.84], + ["COMPLETED", 73, 0.88], + ], + }, + { + name: "Bitbucket Nightly Diff", + prompt: + "Summarize commits landed in acme/legacy-billing since yesterday and flag schema migrations.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/legacy-billing", ref: "master", provider: "bitbucket" }], + trigger: { type: "cron", schedule: "0 2 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 02:00", + lastTriggeredHoursAgo: 13, + runs: [ + ["COMPLETED", 13, 0.21], + ["COMPLETED", 37, 0.19], + ["FAILED", 61, null], + ["COMPLETED", 85, 0.2], + ], + }, + { + name: "Customer Quote Miner", + prompt: + "Read #win-stories and #support. Extract reusable customer quotes and file them in acme/handbook/sales-quotes.md.", + model: "standup-fast", + enabled: false, + repos: [{ url: "acme/handbook", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 15 * * 5", timezone: "America/New_York" }, + scheduleHuman: "Fridays at 15:00", + lastTriggeredHoursAgo: 400, + runs: [ + ["COMPLETED", 400, 0.13], + ], + }, + { + name: "Pending Dispatch Smoke", + prompt: + "No-op smoke automation used to preview a queued PENDING run in the activity log.", + model: "fast", + enabled: true, + trigger: { type: "cron", schedule: "0 * * * *", timezone: "UTC" }, + scheduleHuman: "Hourly", + lastTriggeredHoursAgo: 0.05, + pending: true, + runs: [ + ["COMPLETED", 1.1, 0.03], + ["COMPLETED", 2.1, 0.03], + ], + }, +]; + +function toHexUuid(id) { + return id.replaceAll("-", ""); +} + +function sqlQuote(value) { + if (value == null) return "NULL"; + if (typeof value === "number") return String(value); + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqlite(sql) { + return execFileSync("sqlite3", [DB_PATH, sql], { encoding: "utf8" }).trim(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + "X-Session-API-Key": API_KEY, + "Content-Type": "application/json", + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + if (!response.ok) { + throw new Error( + `${method} ${path} failed (${response.status}): ${typeof data === "string" ? data : JSON.stringify(data)}`, + ); + } + return data; +} + +async function deleteExistingSeeds() { + const list = await api("/api/automation/v1?limit=100"); + const names = new Set(["_probe", ...SEEDS.map((seed) => seed.name)]); + for (const automation of list.automations ?? []) { + if (!names.has(automation.name)) continue; + await api(`/api/automation/v1/${automation.id}`, { method: "DELETE" }); + console.log(`deleted ${automation.name}`); + } +} + +function insertRuns(automationId, seed) { + const hexAutomationId = toHexUuid(automationId); + const rows = []; + + if (seed.running || seed.pending) { + const started = hoursAgo(seed.pending ? 0.05 : 0.25); + const timeout = new Date(Date.now() + 7 * 86_400_000); + rows.push({ + id: toHexUuid(randomUUID()), + status: seed.pending ? "PENDING" : "RUNNING", + error: null, + started, + completed: null, + conversationId: seed.pending + ? null + : `conv-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + bashCommandId: seed.pending + ? null + : `cmd-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + cost: null, + timeout, + }); + } + + for (const [status, hours, cost] of seed.runs) { + const started = hoursAgo(hours); + const durationMs = + status === "SKIPPED" ? 8_000 : 90_000 + Math.round(Math.random() * 180_000); + const completed = + status === "RUNNING" ? null : new Date(started.getTime() + durationMs); + const failedBeforeSandbox = status === "FAILED" && cost == null; + rows.push({ + id: toHexUuid(randomUUID()), + status, + error: + status === "FAILED" + ? failedBeforeSandbox + ? "Sandbox provisioning failed: no available runtime" + : "Process exited with code 1" + : null, + started, + completed, + conversationId: failedBeforeSandbox + ? null + : `conv-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + bashCommandId: failedBeforeSandbox + ? null + : `cmd-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + cost, + timeout: null, + }); + } + + for (const row of rows) { + sqlite(` + INSERT INTO automation_runs ( + id, automation_id, status, error_detail, created_at, started_at, + completed_at, conversation_id, timeout_at, sandbox_id, event_payload, + bash_command_id, telemetry_distinct_id, cost + ) VALUES ( + ${sqlQuote(row.id)}, + ${sqlQuote(hexAutomationId)}, + ${sqlQuote(row.status)}, + ${sqlQuote(row.error)}, + ${sqlQuote(row.started.toISOString())}, + ${sqlQuote(row.started.toISOString())}, + ${sqlQuote(row.completed ? row.completed.toISOString() : null)}, + ${sqlQuote(row.conversationId)}, + ${sqlQuote(row.timeout ? row.timeout.toISOString() : null)}, + ${sqlQuote(row.conversationId ? `sbx-ux-${row.id.slice(0, 8)}` : null)}, + NULL, + ${sqlQuote(row.bashCommandId)}, + NULL, + ${sqlQuote(row.cost)} + ); + `); + } + + return rows.length; +} + +function decorateAutomation(automationId, seed) { + const hexId = toHexUuid(automationId); + const trigger = { + ...seed.trigger, + ...(seed.scheduleHuman ? { schedule_human: seed.scheduleHuman } : {}), + }; + const lastTriggered = + seed.lastTriggeredHoursAgo == null + ? null + : hoursAgo(seed.lastTriggeredHoursAgo).toISOString(); + + sqlite(` + UPDATE automations + SET + trigger = ${sqlQuote(JSON.stringify(trigger))}, + last_triggered_at = ${sqlQuote(lastTriggered)}, + updated_at = ${sqlQuote(new Date().toISOString())} + WHERE id = ${sqlQuote(hexId)}; + `); +} + +async function createSeed(seed) { + const created = await api("/api/automation/v1/preset/prompt", { + method: "POST", + body: { + name: seed.name, + prompt: seed.prompt, + trigger: seed.trigger, + ...(seed.model ? { model: seed.model } : {}), + ...(seed.timeout != null ? { timeout: seed.timeout } : {}), + ...(seed.repos ? { repos: seed.repos } : {}), + }, + }); + + if (created.enabled !== seed.enabled) { + await api(`/api/automation/v1/${created.id}`, { + method: "PATCH", + body: { enabled: seed.enabled }, + }); + } + + decorateAutomation(created.id, seed); + const runCount = insertRuns(created.id, seed); + console.log( + `seeded ${seed.name} (${created.id}) — ${runCount} runs, enabled=${seed.enabled}`, + ); + return created; +} + +async function main() { + const health = await api("/api/automation/health"); + if (health.status !== "ok") { + throw new Error(`Automation backend is not healthy: ${JSON.stringify(health)}`); + } + + await deleteExistingSeeds(); + for (const seed of SEEDS) { + await createSeed(seed); + } + + const list = await api("/api/automation/v1?limit=100"); + console.log(`\n${list.total} automations ready at ${BASE_URL}/automations`); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/scripts/static-build.mjs b/scripts/static-build.mjs new file mode 100644 index 0000000000000000000000000000000000000000..23bb529a85c329497442997b85108a57e024b8b7 --- /dev/null +++ b/scripts/static-build.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { + c, + logError, + logService, + logStep, + logSuccess, +} from "./dev-with-automation.mjs"; +import { buildNpmScriptCommand } from "./dev-safe.mjs"; + +export function buildFrontend(config, args = {}) { + const buildDir = join(config.canvasPath, "build"); + + if (args.skipBuild) { + if (!existsSync(buildDir)) { + logError( + "--skip-build was passed but build/ does not exist. Run without --skip-build first.", + ); + process.exit(1); + } + logStep("build", "Skipping frontend build (--skip-build)"); + logService("build", `Reusing existing build/ at ${buildDir}`, c.dim); + logService( + "build", + "Source edits will NOT appear until you run without --skip-build (or `npm run build`).", + c.yellow, + ); + return; + } + + logStep("build", "Building frontend (npm run build:app)..."); + logService( + "build", + "This typically takes 30-60s; cached as build/ for --skip-build reuse", + c.dim, + ); + + const cmd = buildNpmScriptCommand("build:app"); + const buildEnv = { + ...process.env, + // Bake the session API key — used by the frontend for both agent-server + // and automation auth via the `X-Session-API-Key` header. + VITE_SESSION_API_KEY: config.sessionApiKey, + // Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes + // the runtime fall back to window.location.origin, which keeps the build + // portable across localhost, LAN hosts, and tunnels such as ngrok. + }; + if (config.viteWorkingDir) { + buildEnv.VITE_WORKING_DIR = config.viteWorkingDir; + } + + const result = spawnSync(cmd.command, cmd.args, { + cwd: config.canvasPath, + stdio: "inherit", + env: buildEnv, + }); + + if (result.status !== 0) { + logError(`Build failed with exit code ${result.status ?? "null"}`); + process.exit(result.status ?? 1); + } + + if (!existsSync(join(buildDir, "index.html"))) { + logError( + `Build completed but ${join(buildDir, "index.html")} is missing. ` + + "Did react-router build write somewhere unexpected?", + ); + process.exit(1); + } + + logSuccess("Build complete"); +} diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs new file mode 100644 index 0000000000000000000000000000000000000000..b71bbb0e3bdc64dbddd475ca35e40db4c8747320 --- /dev/null +++ b/scripts/static-server.mjs @@ -0,0 +1,768 @@ +/** + * Combined static file server + reverse proxy. + * + * Replaces `sirv-cli` for the static launcher. The reason a plain static + * server is not enough: Vite's dev server (used by `npm run dev`) configures + * a proxy for `/api`, `/sockets`, `/server_info`, `/alive`, `/health`, + * `/ready`, `/docs`, `/redoc`, `/openapi.json` (see vite.config.ts) so + * requests to those paths are forwarded to + * the agent-server even when the browser is hitting Vite directly on :3001. + * sirv-cli has no proxy support, so under `--single` it falls back to + * index.html for any of those paths — making `/server_info` look like HTML + * to the SPA when the user hits the static port directly (e.g. via a tunnel + * that exposes :3001). + * + * This script provides Vite-equivalent behaviour: serve static files from + * --dir, fall back to index.html for HTML navigations, and reverse-proxy + * configured prefixes to upstream backends. The proxy + WebSocket logic is + * deliberately kept identical in spirit to scripts/ingress.mjs so the two + * servers route the same way. + * + * Usage (mirrors scripts/ingress.mjs's --route flag style): + * node scripts/static-server.mjs \ + * --port 3001 --dir build \ + * --route "/api/automation=http://localhost:18001" \ + * --route "/api=http://localhost:18000" \ + * --route "/server_info=http://localhost:18000" \ + * --route "/sockets=http://localhost:18000" + */ + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import sirv from "sirv"; + +import { + createProxyHandlers, + createRouter, + isServerInfoRequest, + matchesPathPrefix, + proxyServerInfoRequest, +} from "./proxy-utils.mjs"; + +// ───────────────────────────────────────────────────────────────────────────── +// SPA fallback helpers +// ───────────────────────────────────────────────────────────────────────────── + +const ASSET_LIKE_EXTENSIONS = new Set([ + ".br", + ".css", + ".gif", + ".gz", + ".html", + ".htm", + ".ico", + ".jpeg", + ".jpg", + ".js", + ".json", + ".map", + ".mjs", + ".mp3", + ".png", + ".svg", + ".ttf", + ".txt", + ".wav", + ".webmanifest", + ".webp", + ".woff", + ".woff2", + ".xml", +]); + +// ───────────────────────────────────────────────────────────────────────────── +// Args +// ───────────────────────────────────────────────────────────────────────────── + +function isEnvFlagEnabled(value) { + if (typeof value !== "string") return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true"; +} + +export function parseArgs(argv = process.argv.slice(2), env = process.env) { + const config = { + port: 3001, + host: "::", + dir: "build", + routes: {}, + rejectPrefixes: [], + noReferrerPrefixes: [], + sessionApiKey: null, + authRequired: false, + runtimeServicesInfo: null, + lockToCloud: null, + basePath: "/", + vscodeBasePath: null, + // Also settable via the --disable-telemetry flag below. + disableTelemetry: isEnvFlagEnabled(env.AGENT_CANVAS_DISABLE_TELEMETRY), + }; + + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + switch (flag) { + case "-p": + case "--port": + config.port = Number.parseInt(argv[++i], 10); + break; + case "-H": + case "--host": + config.host = argv[++i]; + break; + case "-d": + case "--dir": + config.dir = argv[++i]; + break; + case "-r": + case "--route": { + const value = argv[++i]; + const eq = value.indexOf("="); + if (eq < 0) { + throw new Error(`Invalid --route (expected /prefix=url): ${value}`); + } + const prefix = value.slice(0, eq); + const url = value.slice(eq + 1); + if (!prefix.startsWith("/")) { + throw new Error(`--route prefix must start with '/': ${prefix}`); + } + config.routes[prefix] = url; + break; + } + case "--session-api-key": + config.sessionApiKey = argv[++i] || null; + break; + case "--runtime-services-info": + config.runtimeServicesInfo = argv[++i] || null; + break; + case "--lock-to-cloud": + config.lockToCloud = argv[++i] || null; + break; + case "--base-path": + config.basePath = normalizeBasePath(argv[++i]); + break; + case "--vscode-base-path": { + const prefix = argv[++i]; + if (!prefix || !prefix.startsWith("/")) { + throw new Error( + `--vscode-base-path value must start with '/': ${prefix ?? "(empty)"}`, + ); + } + config.vscodeBasePath = prefix.replace(/\/+$/, "") || "/"; + break; + } + + case "--auth-required": + config.authRequired = true; + break; + case "--disable-telemetry": + config.disableTelemetry = true; + break; + case "--reject-prefix": { + const prefix = argv[++i]; + if (!prefix || !prefix.startsWith("/")) { + throw new Error( + `--reject-prefix value must start with '/': ${prefix ?? "(empty)"}`, + ); + } + config.rejectPrefixes.push(prefix); + break; + } + case "--no-referrer-prefix": { + const prefix = argv[++i]; + if (!prefix || !prefix.startsWith("/")) { + throw new Error( + `--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`, + ); + } + config.noReferrerPrefixes.push(prefix); + break; + } + case "-h": + case "--help": + showHelp(); + process.exit(0); + default: + throw new Error(`Unknown flag: ${flag}`); + } + } + + // Guard: --session-api-key and --auth-required are semantically + // mutually exclusive. The first auto-injects the key (local mode); + // the second forces the user to paste it (public mode). Combining + // both is a misconfiguration. + if (config.sessionApiKey && config.authRequired) { + console.error( + "ERROR: --session-api-key and --auth-required are mutually exclusive.\n" + + " Use --session-api-key for local mode (key auto-injected).\n" + + " Use --auth-required for public mode (user pastes key).", + ); + process.exit(1); + } + + // Guard: advertising the editor and routing it are the same decision, so + // they cannot be allowed to drift. This flag is what the frontend gates the + // editor control on; if it named a prefix with no route behind it, the + // control would render and the navigation would fall through to the SPA — + // which is precisely the bug this flag exists to prevent. + if (config.vscodeBasePath && !config.routes[config.vscodeBasePath]) { + console.error( + `ERROR: --vscode-base-path ${config.vscodeBasePath} has no matching --route.\n` + + " This server would advertise an editor it does not serve.\n" + + ` Add --route ${config.vscodeBasePath}=, or drop --vscode-base-path.`, + ); + process.exit(1); + } + + return config; +} + +function normalizeBasePath(value) { + const raw = (value ?? "").trim(); + if (!raw || raw === "/") return "/"; + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + return withLeadingSlash.replace(/\/+$/, ""); +} + +function showHelp() { + console.log(` +Combined static file server + reverse proxy. + +USAGE: + node scripts/static-server.mjs [options] + +OPTIONS: + -p, --port Port to bind (default: 3001) + -H, --host Hostname to bind (default: :: dual-stack) + -d, --dir

Directory to serve (default: build) + -r, --route Proxy (and subpaths) to ; + may be repeated. WebSockets supported. + --session-api-key Inject session API key into index.html so the + pre-built frontend authenticates to agent-server + without needing VITE_SESSION_API_KEY baked in. + --auth-required Inject authRequired flag into index.html so the + pre-built frontend shows the API key entry screen + (public mode) without VITE_AUTH_REQUIRED baked in. + --runtime-services-info + Inject a JSON description of the local runtime + services into index.html so the pre-built + frontend can populate the agent's + system-prompt block without + VITE_RUNTIME_SERVICES_INFO baked in. + --lock-to-cloud Lock backend setup to a single OpenHands Cloud + URL. Hides manual/local backend setup and the + custom Cloud URL field in the pre-built frontend. + --disable-telemetry Disable all product telemetry (including the + anonymous install event) in the pre-built + frontend at runtime, without VITE_DO_NOT_TRACK + baked in. Injects + window.__AGENT_CANVAS_DO_NOT_TRACK__ = true. + Equivalent to AGENT_CANVAS_DISABLE_TELEMETRY=1. + --base-path Mount the SPA under (default: /). + For example, --base-path /canvas serves + index.html and assets under /canvas. + --vscode-base-path Advertise to the frontend that this origin + serves the editor under , so the editor + control renders here. Requires a matching + --route; the server refuses to start otherwise, + since advertising a prefix it does not route + produces a control that opens the SPA. Omit on + any origin without the editor route. + --reject-prefix Return 503 for requests matching + --no-referrer-prefix

Send "Referrer-Policy: no-referrer" on proxied + responses under

. For upstreams whose URL + carries a credential in the query string. + instead of SPA-fallbacking to index.html; + may be repeated. Useful in --frontend-only + mode to cleanly reject API paths. + -h, --help Show this help + +ROUTING: + • Routes are matched by longest prefix first (most-specific wins). + • Reject prefixes are checked before SPA fallback — matching requests + get 503 immediately. + • Anything that does not match a route or reject prefix is served + from --dir. + • Unknown paths fall back to index.html (SPA mode), unless they look + like an asset request (have a known file extension), in which case + a 404 is returned. +`); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Runtime config injection +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a tiny inline script that seeds runtime config into the page. + * + * - `sessionApiKey`: exposed to the app two ways so a fresh-localStorage + * browser can authenticate even though the published bundle has no + * VITE_SESSION_API_KEY baked in: + * 1. `window.__AGENT_CANVAS_SESSION_API_KEY__` — read by + * `getBakedSessionApiKey()` in `agent-server-config.ts` as a fallback + * when the env var is empty. This is symmetric with how + * `__AGENT_CANVAS_AUTH_REQUIRED__` works for the auth-required flag. + * 2. Written to `openhands-agent-server-config.sessionApiKey` in + * localStorage for compatibility with the legacy storage key. Useful + * for any code path that still reads it (e.g. e2e test fixtures). + * Always overwrites when the stored value differs so a rotated key + * is not shadowed by a stale one. + * + * - `authRequired`: sets `window.__AGENT_CANVAS_AUTH_REQUIRED__ = true` so the + * pre-built frontend shows the API key entry screen (public mode) without + * VITE_AUTH_REQUIRED baked in. + * + * - `runtimeServicesInfo`: a JSON string describing the local services + * (agent-server, automation, …), exposed as + * `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__`. Read by + * `parseRuntimeServicesInfo()` in `agent-server-adapter.ts` as a fallback + * when `VITE_RUNTIME_SERVICES_INFO` is empty, so static builds (Docker / + * published binary) still populate the agent's `` block. + * + * - `lockToCloud`: an OpenHands Cloud URL exposed as + * `window.__AGENT_CANVAS_LOCK_TO_CLOUD__`. Read by `getLockedCloudHost()` in + * `agent-server-config.ts` so pre-built frontend bundles can hide manual + * backend setup and the custom Cloud URL field at runtime. + * + * - `basePath`: the path prefix the SPA is mounted under, exposed as + * `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale + * files can resolve through the same subpath as the built bundle. + * + * - `vscodeBasePath`: the prefix *this origin* serves the editor under, exposed + * as `window.__AGENT_CANVAS_VSCODE_BASE_PATH__`. Read by + * `getOriginVSCodeBasePath()` in `#/utils/vscode-origin` to decide whether the + * editor control can render here at all. Absent means this origin serves no + * editor — which is the correct answer for the public-mode instance, whose + * route table deliberately omits it. + * + * - `disableTelemetry`: sets `window.__AGENT_CANVAS_DO_NOT_TRACK__ = true` so a + * published bundle disables all telemetry (including the anonymous install + * event) at runtime without VITE_DO_NOT_TRACK baked in. Read by + * `isDoNotTrackEnabled()` in `#/services/telemetry`. Enabled by + * AGENT_CANVAS_DISABLE_TELEMETRY=1 or the --disable-telemetry flag. + */ + +/** + * Serialize a value into a safe JavaScript literal for inclusion in an inline breakout) + * - '>' -> \u003e (prevents premature tag closing in some contexts) + * - '\u2028' -> \u2028 (prevents syntax errors in JS parsers) + * - '\u2029' -> \u2029 (prevents syntax errors in JS parsers) + */ +export function serializeForInlineScript(value) { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function makeConfigInjectionScript( + sessionApiKey, + authRequired, + runtimeServicesInfo, + lockToCloud, + basePath, + vscodeBasePath, + disableTelemetry, +) { + const parts = []; + + if (sessionApiKey) { + const keyLiteral = serializeForInlineScript(sessionApiKey); + // Window global — read at module init by getBakedSessionApiKey(). + // Set first so it's available even if the localStorage write throws. + parts.push(`window.__AGENT_CANVAS_SESSION_API_KEY__=${keyLiteral};`); + // Always overwrite when the stored key differs from the runtime key. + // A previous session may have persisted a now-stale key; the runtime + // value (from --session-api-key) is the server's truth. + parts.push( + `try{` + + `var _k='openhands-agent-server-config',` + + `_c=JSON.parse(localStorage.getItem(_k)||'{}');` + + `if(_c.sessionApiKey!==${keyLiteral}){` + + `_c.sessionApiKey=${keyLiteral};` + + `localStorage.setItem(_k,JSON.stringify(_c));` + + `}` + + `}catch(e){}`, + ); + } + + if (authRequired) { + parts.push(`window.__AGENT_CANVAS_AUTH_REQUIRED__=true;`); + } + + if (runtimeServicesInfo) { + // Stored as the raw JSON string so the browser-side parser + // (parseRuntimeServicesInfo) can JSON.parse it exactly like the + // VITE_RUNTIME_SERVICES_INFO env var. serializeForInlineScript produces a safe JS + // string literal for the inline `; +} + +/** + * Serve index.html with runtime config injected into . + * Returns true if the response was written, false if the file was not found. + */ +async function serveInjectedIndexHtml( + req, + res, + indexPath, + { + sessionApiKey, + authRequired, + runtimeServicesInfo, + lockToCloud, + basePath, + vscodeBasePath, + disableTelemetry, + } = {}, +) { + let content; + try { + content = await readFile(indexPath, "utf8"); + } catch { + return false; + } + + const script = makeConfigInjectionScript( + sessionApiKey, + authRequired, + runtimeServicesInfo, + lockToCloud, + basePath, + vscodeBasePath, + disableTelemetry, + ); + // Inject right before so the key is available before any app code runs. + // replace() targets the first (and only) in well-formed HTML. + const injected = content.includes("") + ? content.replace("", `${script}\n`) + : content.includes("") + ? content.replace("", `${script}\n`) + : script + content; + + const buf = Buffer.from(injected, "utf8"); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": buf.length, + "Cache-Control": sessionApiKey ? "no-store" : "no-cache", + }); + if (req.method === "HEAD") { + res.end(); + } else { + res.end(buf); + } + return true; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Static file serving +// ───────────────────────────────────────────────────────────────────────────── + +function parseUrlPath(req, res) { + const rawPath = (req.url ?? "/").split("?")[0]; + try { + return decodeURIComponent(rawPath); + } catch { + res.writeHead(400); + res.end("Bad Request"); + return null; + } +} + +function isGetOrHead(req) { + return req.method === "GET" || req.method === "HEAD"; +} + +function needsRuntimeInjection(injectionOpts) { + return Boolean( + injectionOpts.sessionApiKey || + injectionOpts.authRequired || + injectionOpts.runtimeServicesInfo || + injectionOpts.lockToCloud || + injectionOpts.vscodeBasePath || + injectionOpts.disableTelemetry || + (injectionOpts.basePath && injectionOpts.basePath !== "/"), + ); +} + +function looksLikeAssetRequest(urlPath) { + const last = urlPath.split("/").pop() ?? ""; + return ASSET_LIKE_EXTENSIONS.has(extname(last).toLowerCase()); +} + +function matchesAnyPrefix(urlPath, prefixes) { + return prefixes.some((prefix) => matchesPathPrefix(urlPath, prefix)); +} + +function rejectUnavailable(res) { + res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Service Unavailable (no backend configured for this route)"); +} + +function notFound(res) { + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not Found"); +} + +function isMountedPath(urlPath, basePath) { + return ( + basePath === "/" || + urlPath === basePath || + urlPath.startsWith(`${basePath}/`) + ); +} + +function stripBasePathFromUrl(rawUrl, basePath) { + if (basePath === "/") return rawUrl; + + const [rawPath = "/", ...rest] = (rawUrl || "/").split("?"); + const suffix = rawPath.slice(basePath.length) || "/"; + const path = suffix.startsWith("/") ? suffix : `/${suffix}`; + return rest.length > 0 ? `${path}?${rest.join("?")}` : path; +} + +function redirectToMountedPath(req, res, urlPath, basePath) { + if (basePath === "/" || !isGetOrHead(req) || looksLikeAssetRequest(urlPath)) { + return false; + } + + const [, query = ""] = (req.url ?? "/").split("?", 2); + const path = urlPath === "/" ? "/" : urlPath; + const location = `${basePath}${path}${query ? `?${query}` : ""}`; + res.writeHead(308, { Location: location }); + res.end(); + return true; +} + +function setStaticHeaders(res, pathname) { + const extension = extname(pathname).toLowerCase(); + if (extension === ".js" || extension === ".mjs") { + res.setHeader("Content-Type", "application/javascript; charset=utf-8"); + } + + if (pathname.startsWith("/assets/")) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + return; + } + res.setHeader("Cache-Control", "no-cache"); +} + +function createStaticMiddleware(dirAbs) { + return sirv(dirAbs, { + etag: true, + single: false, + setHeaders: setStaticHeaders, + }); +} + +async function handleStatic( + req, + res, + dirAbs, + staticMiddleware, + injectionOpts = {}, + rejectPrefixes = [], + basePath = "/", +) { + const urlPath = parseUrlPath(req, res); + if (urlPath === null) return; + + if (!isMountedPath(urlPath, basePath)) { + if (matchesAnyPrefix(urlPath, rejectPrefixes)) { + rejectUnavailable(res); + return; + } + if (!redirectToMountedPath(req, res, urlPath, basePath)) notFound(res); + return; + } + + const mountedUrl = stripBasePathFromUrl(req.url ?? "/", basePath); + const mountedPath = parseUrlPath({ ...req, url: mountedUrl }, res); + if (mountedPath === null) return; + + const injectRuntimeConfig = needsRuntimeInjection(injectionOpts); + const indexPath = resolve(dirAbs, "index.html"); + + if ( + injectRuntimeConfig && + isGetOrHead(req) && + (mountedPath === "/" || mountedPath === "/index.html") + ) { + if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) + return; + } + + const mountedReq = Object.create(req); + mountedReq.url = mountedUrl; + + staticMiddleware(mountedReq, res, async () => { + if (matchesAnyPrefix(mountedPath, rejectPrefixes)) { + rejectUnavailable(res); + return; + } + + if (isGetOrHead(req) && !looksLikeAssetRequest(mountedPath)) { + if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) { + return; + } + } + + notFound(res); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Server +// ───────────────────────────────────────────────────────────────────────────── + +export function startStaticServer(config) { + const route = createRouter(config.routes); + const proxy = createProxyHandlers({ label: `static:${config.port}` }); + const dirAbs = resolve(config.dir); + const injectionOpts = { + sessionApiKey: config.sessionApiKey || null, + authRequired: config.authRequired || false, + runtimeServicesInfo: config.runtimeServicesInfo || null, + lockToCloud: config.lockToCloud || null, + basePath: normalizeBasePath(config.basePath), + vscodeBasePath: config.vscodeBasePath || null, + disableTelemetry: config.disableTelemetry || false, + }; + const basePath = injectionOpts.basePath; + const rejectPrefixes = config.rejectPrefixes ?? []; + const noReferrerPrefixes = config.noReferrerPrefixes ?? []; + const staticMiddleware = createStaticMiddleware(dirAbs); + + const uninstallDiagnostics = proxy.installDiagnostics(); + + const server = createServer((req, res) => { + const url = req.url ?? "/"; + const backend = route(url); + if (backend) { + // The editor is advertised as `/?tkn=`, and that + // token is agent-server's session key. The workbench loads webviews, + // previews and extension content from that document, so without this a + // Referer carrying the key rides along on those subrequests. + if (matchesAnyPrefix(url, noReferrerPrefixes)) { + res.setHeader("Referrer-Policy", "no-referrer"); + } + if ( + config.runtimeServicesInfo && + isServerInfoRequest(req) && + (req.method === "GET" || req.method === "HEAD") + ) { + proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); + return; + } + proxy.proxyHttp(req, res, backend); + return; + } + handleStatic( + req, + res, + dirAbs, + staticMiddleware, + injectionOpts, + rejectPrefixes, + basePath, + ).catch((err) => { + console.error(`Static handler error for ${req.url}:`, err); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + }); + }); + + server.on("upgrade", (req, socket, head) => { + const backend = route(req.url ?? "/"); + if (backend) { + proxy.proxyWebSocket(req, socket, head, backend); + return; + } + socket.destroy(); + }); + server.on("close", uninstallDiagnostics); + + return new Promise((resolveListen) => { + server.listen(config.port, config.host, () => { + const displayPath = basePath === "/" ? "/" : `${basePath}/`; + console.log(""); + console.log( + `Static-server + proxy listening on http://${config.host}:${config.port}${displayPath}`, + ); + console.log(` Static dir: ${dirAbs}`); + console.log(` Base path: ${basePath}`); + const sortedRoutes = Object.entries(config.routes).sort( + ([a], [b]) => b.length - a.length, + ); + for (const [prefix, backend] of sortedRoutes) { + console.log(` ${prefix} -> ${backend}`); + } + if (rejectPrefixes.length > 0) { + for (const prefix of rejectPrefixes) { + console.log(` ${prefix} -> 503 (rejected)`); + } + } + if (config.lockToCloud) { + console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`); + } + console.log(" * (default) -> static files + SPA fallback"); + console.log(""); + resolveListen(server); + }); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────────── + +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + try { + const config = parseArgs(); + await startStaticServer(config); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/scripts/stryker-diff.mjs b/scripts/stryker-diff.mjs new file mode 100644 index 0000000000000000000000000000000000000000..194c1b2662f8434cfbb9944ba9c42a8d8d28ee0f --- /dev/null +++ b/scripts/stryker-diff.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const EXCLUDED_DIRECTORY = + /(^|\/)(__tests__|tests?|fixtures|__fixtures__|snapshots|__snapshots__|generated|mocks|dev)(\/|$)/; +const EXCLUDED_FILE = /\.(test|spec|d|gen|generated)\.(ts|tsx)$/; + +function isProductionTypeScript(file) { + const normalized = file.replaceAll("\\", "/"); + return ( + /^src\/.+\.(ts|tsx)$/.test(normalized) && + !EXCLUDED_DIRECTORY.test(normalized) && + !EXCLUDED_FILE.test(normalized) + ); +} + +/** + * @typedef {object} ProcessResult + * @property {Error | undefined} [error] + * @property {number | null} status + * @property {string | null} stderr + * @property {string | null} stdout + */ + +/** + * @typedef {object} Dependencies + * @property {(command: string, args: readonly string[], options?: import("node:child_process").SpawnSyncOptions) => ProcessResult} spawn + * @property {(message: string) => void} writeError + * @property {(message: string) => void} writeOutput + */ + +// Stryker disable all: the real process adapter is verified by the CLI +// subprocess smoke test, which Vitest cannot attribute to the parent process. +/** @type {Dependencies} */ +const defaultDependencies = { + spawn(command, args, options) { + const result = spawnSync(command, [...args], options); + return { + error: result.error, + status: result.status, + stderr: result.stderr?.toString() ?? null, + stdout: result.stdout?.toString() ?? null, + }; + }, + writeError(message) { + process.stderr.write(message); + }, + writeOutput(message) { + process.stdout.write(message); + }, +}; +// Stryker restore all + +/** + * Run mutation testing against production TypeScript files changed from a base + * git ref. The dependency parameter keeps the process boundary observable in + * tests while the command-line behavior remains the public API. + * + * @param {string[]} argv + * @param {Partial} [overrides] + * @returns {number} + */ +export function main(argv, overrides = {}) { + const dependencies = { ...defaultDependencies, ...overrides }; + const baseRef = argv[0] ?? "main"; + const gitArgs = [ + "diff", + "--name-only", + "--diff-filter=ACMRTUXB", + `${baseRef}...HEAD`, + ]; + const gitResult = dependencies.spawn("git", gitArgs, { + encoding: "utf8", + windowsHide: true, + }); + + if (gitResult.error || gitResult.status !== 0) { + const detail = + gitResult.stderr || gitResult.error?.message || "unknown error"; + dependencies.writeError( + `Unable to determine changed files against ${baseRef}: ${detail.trim()}\n`, + ); + return gitResult.status ?? 1; + } + + const mutationTargets = gitResult.stdout + .split(/\r?\n/u) + .filter(isProductionTypeScript); + + if (mutationTargets.length === 0) { + dependencies.writeOutput( + `No changed production files to mutate against ${baseRef}.\n`, + ); + return 0; + } + + const strykerCliPath = fileURLToPath( + new URL( + "../node_modules/@stryker-mutator/core/bin/stryker.js", + import.meta.url, + ), + ); + const strykerResult = dependencies.spawn( + process.execPath, + [ + strykerCliPath, + "run", + "--incremental", + "--force", + "--mutate", + mutationTargets.join(","), + ], + { stdio: "inherit", windowsHide: true }, + ); + + if (strykerResult.error) { + dependencies.writeError( + `Unable to start Stryker: ${strykerResult.error.message}\n`, + ); + return 1; + } + + return strykerResult.status ?? 1; +} + +// Stryker disable all: the CLI guard is verified by a real subprocess test, +// which the Vitest runner cannot attribute to the mutated parent process. +const isMainModule = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + process.exitCode = main(process.argv.slice(2)); +} +// Stryker restore all diff --git a/scripts/vercel-install.sh b/scripts/vercel-install.sh new file mode 100644 index 0000000000000000000000000000000000000000..a12ad2249be21de8a792f337d6486e23638e417a --- /dev/null +++ b/scripts/vercel-install.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Custom Vercel install command. +# +# npm normalizes any GitHub URL it finds in package.json (including +# `git+https://github.com/...` and the `github:owner/repo` shorthand) to +# `git+ssh://git@github.com/...` when it writes package-lock.json. Vercel's +# build environment has no SSH key for GitHub, so npm cannot clone the +# `@openhands/typescript-client` git dependency and silently falls back to a +# stale cached copy — producing the dreaded +# `[MISSING_EXPORT] ConversationClient is not exported by +# node_modules/@openhands/typescript-client/dist/clients.js` at bundle time. +# +# Two defensive measures here: +# 1. Rewrite any `git+ssh://git@github.com/` URLs in package-lock.json +# to `git+https://github.com/` before invoking npm so the lockfile +# Vercel actually consumes is HTTPS-only, regardless of which lockfile +# shape happened to be committed. +# 2. Configure git globally to translate the matching ssh forms into +# https — this catches anything npm has already cached as an ssh URL +# and any future git deps that hit the same bug. +# +# See https://github.com/OpenHands/agent-canvas/issues/384 for the original +# bug report. +set -euo pipefail + +if [ -f package-lock.json ]; then + sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json +fi + +git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" +git config --global url."https://github.com/".insteadOf "git@github.com:" + +npm ci diff --git a/src/entry.client.tsx b/src/entry.client.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e72274f9484f0a0795e7a1eb8b1783ca204ca6e --- /dev/null +++ b/src/entry.client.tsx @@ -0,0 +1,49 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { HydratedRouter } from "react-router/dom"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { + AgentServerUIProviders, + DEFAULT_AGENT_SERVER_ANALYTICS, +} from "./components/providers"; +import { waitForI18n } from "./i18n"; +import { shouldStartMockWorker } from "./mocks/should-start-mock-worker"; + +async function prepareApp() { + await waitForI18n(); + + if (shouldStartMockWorker()) { + const { worker } = await import("./mocks/browser"); + + await worker.start({ + onUnhandledRequest: "bypass", + }); + } + + if (import.meta.env.DEV) { + const { installPendingChatPreview } = + await import("./dev/seed-pending-chat-preview"); + installPendingChatPreview(); + } +} + +prepareApp().then(() => + startTransition(() => { + hydrateRoot( + document, + + + + + , + ); + }), +); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..766e7e9810b20d82658757cf5f282894cc62e13c --- /dev/null +++ b/src/index.css @@ -0,0 +1,136 @@ +@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Outfit:wght@100..900&display=swap"); + +:root { + --oh-bg-dark: var(--cool-grey-950); + --oh-bg-light: var(--cool-grey-900); + --oh-bg-input: var(--cool-grey-800); + --oh-bg-workspace: var(--cool-grey-925); + --oh-text-editor-base: var(--cool-grey-400); + --oh-text-editor-active: var(--cool-grey-300); + --oh-bg-editor-sidebar: var(--cool-grey-925); + --oh-bg-editor-active: var(--cool-grey-900); + --oh-border-editor-sidebar: var(--cool-grey-800); + --oh-bg-neutral-muted: color-mix(in srgb, var(--cool-grey-300) 20%, transparent); + --bg-dark: var(--oh-bg-dark); + --bg-light: var(--oh-bg-light); + --bg-input: var(--oh-bg-input); + --bg-workspace: var(--oh-bg-workspace); + --text-editor-base: var(--oh-text-editor-base); + --text-editor-active: var(--oh-text-editor-active); + --bg-editor-sidebar: var(--oh-bg-editor-sidebar); + --bg-editor-active: var(--oh-bg-editor-active); + --border-editor-sidebar: var(--oh-border-editor-sidebar); + --bg-neutral-muted: var(--oh-bg-neutral-muted); + background-color: var(--oh-color-base) !important; + + /* ── Cool Grey Scale (13 shades + 2 pure anchors: white / black) ──────── */ + --cool-grey-50: #F7F9FC; + --cool-grey-100: #EEF2F7; + --cool-grey-200: #DCE3EE; + --cool-grey-300: #C3CDDC; + --cool-grey-400: #A3B0C4; + --cool-grey-500: #7E8A9E; + --cool-grey-600: #626D82; + --cool-grey-700: #4B5468; + --cool-grey-800: #383F50; + --cool-grey-900: #2C313F; + --cool-grey-925: #21252F; + --cool-grey-950: #0B0E14; /* intentional override: preserves original app-shell depth */ + --cool-grey-975: #05070A; + /* ─────────────────────────────────────────────────────────────────────── */ +} + +body { + margin: 0; + background-color: var(--oh-color-base); + font-family: + -apple-system, "SF Pro", BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--cool-grey-900); + border-radius: 4px; + color: var(--cool-grey-100); + border: 1px solid var(--cool-grey-900); + letter-spacing: -0.2px; +} + +.markdown-body pre code { + padding: 0; + background-color: inherit; +} + +.markdown-body { + white-space: pre-wrap; /* Handles line breaks */ +} + +.markdown-body th { + text-align: left; +} + +.markdown-body th, +.markdown-body td { + padding: 0.1rem 1rem; +} + +/* Fast smooth scrolling for chat interface */ +.fast-smooth-scroll { + scroll-behavior: smooth; + scroll-timeline: 100ms; +} + +@keyframes environment-switch-content-refresh { + 0% { + opacity: 1; + } + 30% { + opacity: 0; + } + 70% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +:host[data-environment-switching="true"] [data-testid="root-layout"] { + animation: environment-switch-content-refresh 980ms ease-in-out forwards; +} + +@keyframes environment-switch-overlay-fade { + 0% { + opacity: 0; + transform: scale(0.98); + } + 15% { + opacity: 1; + transform: scale(1); + } + 85% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.98); + } +} + +.environment-switch-overlay > div { + animation: environment-switch-overlay-fade 980ms ease-in-out forwards; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cd5167d1c7f0099e344669af2dce7fefc42d301 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from "./lib"; diff --git a/src/library-env.d.ts b/src/library-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..c802163fd7bd71ea3830766397d00bebbeaf6761 --- /dev/null +++ b/src/library-env.d.ts @@ -0,0 +1,6 @@ +/// +/// + +interface Window { + __GITHUB_CLIENT_ID__?: string | null; +} diff --git a/src/mocks/mcp-handlers.ts b/src/mocks/mcp-handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9afa7f534b62a0dc106b42f159749911d5b6115 --- /dev/null +++ b/src/mocks/mcp-handlers.ts @@ -0,0 +1,23 @@ +import type { MCPTestResponse } from "@openhands/typescript-client"; +import { http, HttpResponse } from "msw"; + +/** + * MSW handlers for the MCP API. + * + * Currently only the pre-flight connectivity check (`POST /api/mcp/test`) + * needs a mock — the install/save flow in `InstallServerModal` and + * `CustomServerEditor` calls it before persisting the new server via the + * existing settings PATCH. In mock mode there is no real MCP server to + * connect to, so we return a deterministic success response so the install + * flow can complete in `npm run dev:mock`. + */ +const MOCK_MCP_TEST_SUCCESS: MCPTestResponse = { + ok: true, + tools: [], +}; + +export const MCP_HANDLERS = [ + http.post("*/api/mcp/test", async () => + HttpResponse.json(MOCK_MCP_TEST_SUCCESS), + ), +]; diff --git a/src/query-client-config.ts b/src/query-client-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4d28ea8819c5ec45375c0ed0e4235215fbb1ffb --- /dev/null +++ b/src/query-client-config.ts @@ -0,0 +1,117 @@ +import { QueryCache, MutationCache, QueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import i18n from "#/i18n"; +import { I18nKey } from "./i18n/declaration"; +import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"; +import { displayErrorToast } from "./utils/custom-toast-handlers"; +import { getActiveBackend } from "#/api/backend-registry/active-store"; +import { recordBackendSuccess } from "#/api/backend-registry/health-store"; + +const handle401Error = (error: AxiosError, client: QueryClient) => { + if (error?.response?.status === 401 || error?.status === 401) { + client.invalidateQueries({ queryKey: ["user", "authenticated"] }); + } +}; + +const isActiveCloudBackendAuthError = (error: unknown) => { + if (!(error instanceof AxiosError)) return false; + if (error.response?.status !== 401 && error.status !== 401) return false; + + const activeBackend = getActiveBackend().backend; + if (activeBackend.kind !== "cloud") return false; + + const requestUrl = error.config?.url; + return ( + !requestUrl || requestUrl.startsWith(activeBackend.host.replace(/\/+$/, "")) + ); +}; + +const shownErrors = new Set(); + +export const createAgentServerQueryClient = () => { + const client = new QueryClient({ + queryCache: new QueryCache({ + onSuccess: (_data, query) => { + const backendId = + query.meta?.backendId ?? query.options.meta?.backendId; + if (typeof backendId === "string") { + recordBackendSuccess(backendId); + } + }, + onError: (error, query) => { + const isAuthQuery = + query.queryKey[0] === "user" && query.queryKey[1] === "authenticated"; + if (!isAuthQuery) { + handle401Error(error, client); + } + + const disableToast = + query.meta?.disableToast ?? query.options.meta?.disableToast; + + if (!disableToast && !isActiveCloudBackendAuthError(error)) { + const errorMessage = retrieveAxiosErrorMessage(error); + + if (!shownErrors.has(errorMessage || "")) { + displayErrorToast(errorMessage || i18n.t(I18nKey.ERROR$GENERIC)); + shownErrors.add(errorMessage || ""); + + setTimeout(() => { + shownErrors.delete(errorMessage || ""); + }, 3000); + } + } + }, + }), + mutationCache: new MutationCache({ + onError: (error, _, __, mutation) => { + handle401Error(error, client); + + const disableToast = + mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast; + + if (!disableToast && !isActiveCloudBackendAuthError(error)) { + const message = retrieveAxiosErrorMessage(error); + displayErrorToast(message || i18n.t(I18nKey.ERROR$GENERIC)); + } + }, + }), + }); + + return client; +}; + +let defaultQueryClient: QueryClient | null = null; +let activeQueryClient: QueryClient | null = null; + +export const getDefaultQueryClient = () => { + if (!defaultQueryClient) { + defaultQueryClient = createAgentServerQueryClient(); + if (import.meta.env.DEV || import.meta.env.VITE_MOCK_API === "true") { + ( + window as unknown as { __OH_QUERY_CLIENT__?: typeof defaultQueryClient } + ).__OH_QUERY_CLIENT__ = defaultQueryClient; + } + } + + return defaultQueryClient; +}; + +export const getQueryClient = () => + activeQueryClient ?? getDefaultQueryClient(); + +export const setQueryClient = (client?: QueryClient | null) => { + activeQueryClient = client ?? getDefaultQueryClient(); + return activeQueryClient; +}; + +export const queryClient = new Proxy({} as QueryClient, { + get: (_target, prop) => { + const client = getQueryClient(); + const value = Reflect.get(client, prop, client); + return typeof value === "function" ? value.bind(client) : value; + }, + set: (_target, prop, value) => { + const client = getQueryClient(); + return Reflect.set(client, prop, value, client); + }, +}) as QueryClient; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..b49357adbbcc4534e92f7a1b9d78f55ded43a754 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1,6 @@ +/// + +// Injected by vite.config.ts `define` — absolute path to the +// @openhands/extensions skills directory in node_modules, or an +// empty string in library builds. +declare const __EXTENSIONS_SKILLS_DIR__: string; diff --git a/src/root.test.tsx b/src/root.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa71f2494a059b6d4a9cc58d8b332f036040b713 --- /dev/null +++ b/src/root.test.tsx @@ -0,0 +1,93 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import { + __resetHealthStoreForTests, + recordBackendFailure, +} from "#/api/backend-registry/health-store"; +import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"; +import { CLOUD_BACKEND_API_KEY_OR_NETWORK_ERROR } from "#/hooks/query/use-backends-health"; +import type { Backend } from "#/api/backend-registry/types"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; +import App from "#/root"; + +// The recovery screen lazy-loads the Manage Backends modal; stub it so the test +// asserts the routing decision rather than the modal's internals. +vi.mock("#/components/features/backends/manage-backends-modal", () => ({ + ManageBackendsModal: () =>

, +})); + +const cloudBackend: Backend = { + id: "cloud-ohe", + name: "Adorable Enterprise", + host: "https://app.adorable.build.one", + apiKey: "oh-cloud-key", + kind: "cloud", +}; + +function renderApp() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const router = createMemoryRouter( + [ + { + path: "/", + Component: App, + children: [ + { index: true, element:
}, + ], + }, + ], + { initialEntries: ["/"] }, + ); + + return render( + + + + + , + ); +} + +describe("App root — active cloud backend connectivity gate", () => { + beforeEach(() => { + localStorage.clear(); + __resetHealthStoreForTests(); + localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + setRegisteredBackends([cloudBackend]); + setActiveSelection({ backendId: cloudBackend.id }); + }); + + afterEach(() => { + setActiveSelection(null); + setRegisteredBackends([]); + localStorage.clear(); + __resetHealthStoreForTests(); + }); + + it("shows the backend recovery screen when the active cloud backend is unreachable (CORS/network)", async () => { + // Emulate a self-hosted OHE that doesn't allow this frontend's origin: + // repeated CORS/network probe failures until the backend is disabled. + for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) { + recordBackendFailure( + cloudBackend.id, + new Error(CLOUD_BACKEND_API_KEY_OR_NETWORK_ERROR), + ); + } + + renderApp(); + + expect( + await screen.findByTestId("agent-server-onboarding-screen"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("app-outlet-content")).not.toBeInTheDocument(); + }); +}); diff --git a/src/root.tsx b/src/root.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d3cecdd3ab25688494e017bf5881ca92ca1b160 --- /dev/null +++ b/src/root.tsx @@ -0,0 +1,398 @@ +import { + Links, + LinksFunction, + Meta, + MetaFunction, + Outlet, + Scripts, + ScrollRestoration, + useLocation, + useNavigate, + useNavigation as useRouterNavigation, +} from "react-router"; +import "./tailwind.css"; +import "./index.css"; +import React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Toaster } from "react-hot-toast"; +import { + clearCachedAgentServerInfo, + isAgentServerUnavailableError, + isAgentServerAuthError, +} from "#/api/agent-server-compatibility"; +import { + getLockedCloudAuthMode, + getLockedCloudHost, + isAuthRequiredAndMissing, + isSameCloudHost, +} from "#/api/agent-server-config"; +import { + authenticateWithMainAppCookie, + redirectToMainAppLogin, + shouldUseMainAppCookieAuth, +} from "#/api/main-app-auth"; +import { getEffectiveLocalBackend } from "#/api/backend-registry/active-store"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { + isCloudBackendApiKeyOrNetworkHealthError, + isCloudBackendLoggedOutHealthError, + useBackendsHealth, +} from "#/hooks/query/use-backends-health"; +import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { useConfig } from "#/hooks/query/use-config"; +import { QUERY_KEYS } from "#/hooks/query/query-keys"; +import { AgentServerUIRoot } from "#/components/providers"; +import { TelemetryConsentBanner } from "#/components/features/analytics/telemetry-consent-banner"; +import { buildAgentCanvasPath } from "#/utils/base-path"; +import { useOnboardingCompletion } from "#/components/features/onboarding/use-onboarding-completion"; +import { NavigationProvider } from "#/context/navigation-context"; +import { + applyColorTheme, + readPersistedColorTheme, +} from "#/themes/color-themes"; + +/** Applies the persisted color-theme palette to document.body on mount. */ +function ColorThemeApplier() { + React.useEffect(() => { + applyColorTheme(readPersistedColorTheme()); + }, []); + return null; +} + +// Only rendered when the active backend is unreachable; keep the modal out of +// the default root graph. +const ManageBackendsModal = React.lazy(() => + import("#/components/features/backends/manage-backends-modal").then((m) => ({ + default: m.ManageBackendsModal, + })), +); + +// Rendered when the backend returns 401 (public mode — user must paste key). +const ApiKeyEntryScreen = React.lazy( + () => import("#/components/features/backends/api-key-entry-screen"), +); + +// Rendered only for first-run public/frontend-only bootstraps; keep the +// onboarding flow out of the root bundle until this rare gate is active. +const OnboardingModal = React.lazy(() => + import("#/components/features/onboarding/onboarding-modal").then((m) => ({ + default: m.OnboardingModal, + })), +); + +// Rendered for first-run in locked-to-Cloud mode; shows Cloud login directly +// without the onboarding progress bars. +const BackendFormModal = React.lazy(() => + import("#/components/features/backends/backend-form-modal").then((m) => ({ + default: m.BackendFormModal, + })), +); + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + + {children} + +