SaylorTwift HF Staff commited on
Commit
c0af099
Β·
verified Β·
1 Parent(s): ee9fcd0

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .github/dependabot.yml +110 -0
  2. .github/pull_request_template.md +65 -0
  3. .github/release.yml +14 -0
  4. __tests__/MSW.md +134 -0
  5. __tests__/agent-server-ui-providers.test.tsx +279 -0
  6. __tests__/agent-server-ui-style-scope.test.ts +52 -0
  7. __tests__/build-websocket-url.test.ts +269 -0
  8. __tests__/conversation-local-storage.test.ts +692 -0
  9. __tests__/initial-query.test.tsx +24 -0
  10. __tests__/library-entrypoints.test.ts +49 -0
  11. __tests__/package-library.test.ts +158 -0
  12. __tests__/query-client-config.behavior.test.ts +522 -0
  13. __tests__/query-client-config.test.ts +92 -0
  14. __tests__/root.test.tsx +926 -0
  15. __tests__/router.md +227 -0
  16. __tests__/settings-schema-descriptions.test.ts +21 -0
  17. __tests__/vite-config.test.ts +98 -0
  18. __tests__/vitest-setup-progress-event.test.ts +62 -0
  19. docs/ACP_AGENTS.md +239 -0
  20. docs/CANVAS_EXTENSIONS_TESTING.md +93 -0
  21. docs/DEVELOPMENT.md +205 -0
  22. docs/DefenseClaw.md +303 -0
  23. docs/README.md +11 -0
  24. electron/loading.html +359 -0
  25. electron/main.mjs +785 -0
  26. electron/package.json +7 -0
  27. electron/preload.cjs +31 -0
  28. public/android-chrome-192x192.png +0 -0
  29. public/android-chrome-512x512.png +0 -0
  30. public/apple-touch-icon.png +0 -0
  31. public/browserconfig.xml +9 -0
  32. public/favicon-16x16.png +0 -0
  33. public/favicon-32x32.png +0 -0
  34. public/favicon.ico +0 -0
  35. public/favicon.svg +1 -0
  36. public/mockServiceWorker.js +361 -0
  37. public/mstile-150x150.png +0 -0
  38. public/robots.txt +3 -0
  39. public/safari-pinned-tab.svg +7 -0
  40. public/site.webmanifest +19 -0
  41. scripts/brand-dev-electron.mjs +244 -0
  42. scripts/check-sdk-version-sync.mjs +500 -0
  43. scripts/check-translation-completeness.cjs +200 -0
  44. scripts/dev-extra-backend.mjs +262 -0
  45. scripts/dev-process-utils.mjs +150 -0
  46. scripts/dev-safe.mjs +1217 -0
  47. scripts/dev-static.mjs +665 -0
  48. scripts/dev-with-automation.mjs +1715 -0
  49. scripts/docker-build.mjs +75 -0
  50. scripts/download-node.mjs +382 -0
.github/dependabot.yml ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: npm
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+ day: monday
8
+ time: "09:00"
9
+ timezone: "UTC"
10
+ cooldown:
11
+ default-days: 7
12
+ versioning-strategy: increase
13
+ open-pull-requests-limit: 10
14
+ commit-message:
15
+ prefix: chore
16
+ include: scope
17
+ labels:
18
+ - dependencies
19
+ - npm
20
+ # Group related packages together so they ship in a single PR.
21
+ # Packages that aren't matched by any group still get their own PR
22
+ # (the default behavior), which is what we want for high-impact deps
23
+ # like vite, react-router, framer-motion, etc.
24
+ groups:
25
+ tailwind:
26
+ patterns:
27
+ - "tailwindcss"
28
+ - "@tailwindcss/*"
29
+ - "tailwind-merge"
30
+ - "tailwind-scrollbar"
31
+ tanstack:
32
+ patterns:
33
+ - "@tanstack/*"
34
+ i18next:
35
+ patterns:
36
+ - "i18next"
37
+ - "i18next-*"
38
+ - "react-i18next"
39
+ - "eslint-plugin-i18next"
40
+ react:
41
+ patterns:
42
+ - "react"
43
+ - "react-dom"
44
+ - "@types/react"
45
+ - "@types/react-dom"
46
+ - "@types/react-*"
47
+ - "eslint-plugin-react"
48
+ - "eslint-plugin-react-hooks"
49
+ react-router:
50
+ patterns:
51
+ - "react-router"
52
+ - "@react-router/*"
53
+ - "@vercel/react-router"
54
+ - "isbot"
55
+ testing:
56
+ patterns:
57
+ - "vitest"
58
+ - "@vitest/*"
59
+ - "@testing-library/*"
60
+ - "jsdom"
61
+ - "@playwright/test"
62
+ - "msw"
63
+ - "@mswjs/*"
64
+ eslint:
65
+ patterns:
66
+ - "eslint"
67
+ - "eslint-config-*"
68
+ - "eslint-plugin-*"
69
+ - "@typescript-eslint/*"
70
+ - "prettier"
71
+ exclude-patterns:
72
+ # These are grouped under their feature area instead.
73
+ - "eslint-plugin-i18next"
74
+ - "eslint-plugin-react"
75
+ - "eslint-plugin-react-hooks"
76
+ monaco:
77
+ patterns:
78
+ - "monaco-editor"
79
+ - "@monaco-editor/*"
80
+ xterm:
81
+ patterns:
82
+ - "@xterm/*"
83
+ types:
84
+ patterns:
85
+ - "@types/*"
86
+ exclude-patterns:
87
+ - "@types/react"
88
+ - "@types/react-dom"
89
+ - "@types/react-*"
90
+
91
+ - package-ecosystem: github-actions
92
+ directory: "/"
93
+ schedule:
94
+ interval: weekly
95
+ day: monday
96
+ time: "09:00"
97
+ timezone: "UTC"
98
+ cooldown:
99
+ default-days: 7
100
+ open-pull-requests-limit: 5
101
+ commit-message:
102
+ prefix: ci
103
+ include: scope
104
+ labels:
105
+ - dependencies
106
+ - github-actions
107
+ groups:
108
+ actions:
109
+ patterns:
110
+ - "*"
.github/pull_request_template.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Keep this PR as draft until it is ready for review. -->
2
+
3
+ HUMAN:
4
+
5
+ <!-- Human contributors: add a short note about your testing. -->
6
+
7
+ ---
8
+
9
+ AGENT:
10
+
11
+ <!-- AI/LLM agents:
12
+ Do not edit the HUMAN section.
13
+ Write a concise summary of what changed and link any reviewer artifacts, such
14
+ as files under `.pr/`. For HTML artifacts, include a rendered preview link:
15
+ https://htmlpreview.github.io/?https://github.com/<owner>/<repo>/blob/<branch>/.pr/<file>.html
16
+ In this AGENT section and the template fields below, provide evidence that the
17
+ code runs properly end-to-end. Just running unit tests is NOT sufficient. Explain
18
+ exactly what command you ran and include logs, screenshots, or reproduction notes.
19
+ -->
20
+
21
+ ## Why
22
+
23
+ <!-- Describe problem, motivation, etc. -->
24
+
25
+ ## Summary
26
+
27
+ <!-- 1-3 bullets describing what changed. -->
28
+ -
29
+
30
+ ## Issue Number
31
+ <!-- Required. The linked issue must carry the `ready-for-dev` label, which
32
+ means it has clear acceptance criteria (and, for bugs, reproduction evidence).
33
+ If no such issue exists yet, open one using the Bug or Feature Request template
34
+ and wait for it to be labeled `ready-for-dev` before opening this PR. -->
35
+ Fixes #
36
+
37
+ ## How to Test
38
+
39
+ <!--
40
+ Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
41
+
42
+ If you could not test this, say why.
43
+ -->
44
+
45
+ ## Video/Screenshots
46
+
47
+ <!--
48
+ Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
49
+
50
+ For bug fixes: reproduction evidence is required. Show the bug reproduced (the
51
+ error state) and then the result after your fix. A terminal screenshot or video
52
+ is fine for non-UI bugs.
53
+ -->
54
+
55
+ ## Type
56
+
57
+ - [ ] Bug fix
58
+ - [ ] Feature
59
+ - [ ] Refactor
60
+ - [ ] Breaking change
61
+ - [ ] Docs / chore
62
+
63
+ ## Notes
64
+
65
+ <!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
.github/release.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ changelog:
2
+ categories:
3
+ - title: Features
4
+ labels: ["type: feat"]
5
+ - title: Bug Fixes
6
+ labels: ["type: fix"]
7
+ - title: Performance
8
+ labels: ["type: perf"]
9
+ - title: Documentation
10
+ labels: ["type: docs"]
11
+ - title: Maintenance
12
+ labels: ["type: chore", "type: build", "type: ci", "type: refactor", "type: style", "type: test", "type: revert"]
13
+ - title: Other Changes
14
+ labels: ["*"]
__tests__/MSW.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mock Service Worker (MSW) Guide
2
+
3
+ ## Overview
4
+
5
+ [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.
6
+
7
+ We use MSW in this project for:
8
+ - **Testing**: Write reliable unit and integration tests without real network calls
9
+ - **Development**: Run the frontend with mocked APIs when the backend isn't available or when working on features with pending backend APIs
10
+
11
+ The same mock handlers work in both environments, so you write them once and reuse everywhere.
12
+
13
+ ## Relevant Files
14
+
15
+ - `src/mocks/handlers.ts` - Main handler registry that combines all domain handlers
16
+ - `src/mocks/*-handlers.ts` - Domain-specific handlers (auth, conversation, etc.)
17
+ - `src/mocks/browser.ts` - Browser setup for development mode
18
+ - `src/mocks/node.ts` - Node.js setup for tests
19
+ - `vitest.setup.ts` - Global test setup with MSW lifecycle hooks
20
+
21
+ ## Development Workflow
22
+
23
+ ### Running with Mocked APIs
24
+
25
+ ```sh
26
+ # Run with API mocking enabled
27
+ npm run dev:mock
28
+ ```
29
+
30
+ This command sets `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests.
31
+
32
+
33
+ ## Writing Tests
34
+
35
+ ### Service Layer Mocking (Recommended)
36
+
37
+ For most tests, mock at the service layer using `vi.spyOn`. This approach is explicit, test-scoped, and makes the scenario being tested clear.
38
+
39
+ ```typescript
40
+ import { vi } from "vitest";
41
+ import SettingsService from "#/api/settings-service/settings-service.api";
42
+
43
+ const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
44
+ getSettingsSpy.mockResolvedValue({
45
+ llm_model: "openai/gpt-4o",
46
+ llm_api_key_set: true,
47
+ // ... other settings
48
+ });
49
+ ```
50
+
51
+ Use `mockResolvedValue` for success scenarios and `mockRejectedValue` for error scenarios:
52
+
53
+ ```typescript
54
+ getSettingsSpy.mockRejectedValue(new Error("Failed to fetch settings"));
55
+ ```
56
+
57
+ ### Network Layer Mocking (Advanced)
58
+
59
+ For tests that need actual network-level behavior (WebSockets, testing retry logic, etc.), use `server.use()` to override handlers per test.
60
+
61
+ > [!IMPORTANT]
62
+ > **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.
63
+
64
+ ```typescript
65
+ import { http, HttpResponse } from "msw";
66
+ import { server } from "#/mocks/node";
67
+
68
+ it("should handle server errors", async () => {
69
+ server.use(
70
+ http.get("/api/my-endpoint", () => {
71
+ return new HttpResponse(null, { status: 500 });
72
+ }),
73
+ );
74
+ // ... test code
75
+ });
76
+ ```
77
+
78
+ For WebSocket testing, see `__tests__/helpers/msw-websocket-setup.ts` for utilities.
79
+
80
+ ## Adding New API Mocks
81
+
82
+ When adding new API endpoints, create mocks in both places to maintain 1:1 similarity with the backend:
83
+
84
+ ### 1. Add to `src/mocks/` (for development)
85
+
86
+ Create or update a domain-specific handler file:
87
+
88
+ ```typescript
89
+ // src/mocks/my-feature-handlers.ts
90
+ import { http, HttpResponse } from "msw";
91
+
92
+ export const MY_FEATURE_HANDLERS = [
93
+ http.get("/api/my-feature", () => {
94
+ return HttpResponse.json({
95
+ data: "mock response",
96
+ });
97
+ }),
98
+ ];
99
+ ```
100
+
101
+ Register in `handlers.ts`:
102
+
103
+ ```typescript
104
+ import { MY_FEATURE_HANDLERS } from "./my-feature-handlers";
105
+
106
+ export const handlers = [
107
+ // ... existing handlers
108
+ ...MY_FEATURE_HANDLERS,
109
+ ];
110
+ ```
111
+
112
+ ### 2. Mock in tests for specific scenarios
113
+
114
+ In your test files, spy on the service method to control responses per test case:
115
+
116
+ ```typescript
117
+ import { vi } from "vitest";
118
+ import MyFeatureService from "#/api/my-feature-service.api";
119
+
120
+ const spy = vi.spyOn(MyFeatureService, "getData");
121
+ spy.mockResolvedValue({ data: "test-specific response" });
122
+ ```
123
+
124
+ See `__tests__/routes/llm-settings.test.tsx` for a real-world example of service layer mocking.
125
+
126
+ > [!TIP]
127
+ > For guidance on creating service APIs, see `src/api/README.md`.
128
+
129
+ ## Best Practices
130
+
131
+ - **Keep mocks close to real API contracts** - Update mocks when backend changes
132
+ - **Use service layer mocking for most tests** - It's simpler and more explicit
133
+ - **Reserve network layer mocking for integration tests** - WebSockets, retry logic, etc.
134
+ - **Export mock data from handler files** - Reuse in tests (e.g., `MOCK_DEFAULT_USER_SETTINGS`)
__tests__/agent-server-ui-providers.test.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import { QueryClient, useQueryClient } from "@tanstack/react-query";
5
+ import { createInstance } from "i18next";
6
+ import { initReactI18next, useTranslation } from "react-i18next";
7
+
8
+ vi.mock("react-i18next", async (importOriginal) =>
9
+ importOriginal<typeof import("react-i18next")>(),
10
+ );
11
+
12
+ import {
13
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
14
+ AgentServerUIRoot,
15
+ AgentServerUIProviders,
16
+ OPENHANDS_I18N_NAMESPACE,
17
+ getDefaultI18n,
18
+ getDefaultQueryClient,
19
+ getI18n,
20
+ getQueryClient,
21
+ queryClient,
22
+ setI18n,
23
+ setQueryClient,
24
+ } from "#/index";
25
+ import i18n from "#/i18n";
26
+
27
+ const telemetryProviderMock = vi.hoisted(() => vi.fn());
28
+ vi.mock("#/components/providers/telemetry-provider", () => ({
29
+ TelemetryProvider: (props: {
30
+ children: React.ReactNode;
31
+ config?: unknown;
32
+ }) => {
33
+ telemetryProviderMock(props);
34
+ return props.children;
35
+ },
36
+ }));
37
+
38
+ const BaseProbe = ({ translation }: { translation?: string }) => {
39
+ const currentQueryClient = useQueryClient();
40
+
41
+ return (
42
+ <div>
43
+ <div data-testid="query-client-kind">
44
+ {currentQueryClient === getDefaultQueryClient() ? "default" : "custom"}
45
+ </div>
46
+ <div data-testid="query-client-value">
47
+ {String(queryClient.getQueryData(["provider-probe"]))}
48
+ </div>
49
+ {translation && <div data-testid="translation-value">{translation}</div>}
50
+ <div data-testid="imperative-translation-value">
51
+ {i18n.t("PROVIDER$LABEL")}
52
+ </div>
53
+ </div>
54
+ );
55
+ };
56
+
57
+ const DefaultProbe = () => <BaseProbe />;
58
+
59
+ const CustomProbe = () => {
60
+ const { t } = useTranslation(OPENHANDS_I18N_NAMESPACE);
61
+
62
+ return <BaseProbe translation={t("PROVIDER$LABEL")} />;
63
+ };
64
+
65
+ const createTestI18n = async (value: string) => {
66
+ const instance = createInstance();
67
+
68
+ await instance.use(initReactI18next).init({
69
+ lng: "en",
70
+ fallbackLng: "en",
71
+ ns: ["host", OPENHANDS_I18N_NAMESPACE],
72
+ defaultNS: "host",
73
+ interpolation: { escapeValue: false },
74
+ resources: {
75
+ en: {
76
+ host: {
77
+ PROVIDER$LABEL: "Host provider",
78
+ },
79
+ [OPENHANDS_I18N_NAMESPACE]: {
80
+ PROVIDER$LABEL: value,
81
+ },
82
+ },
83
+ },
84
+ });
85
+
86
+ return instance;
87
+ };
88
+
89
+ afterEach(() => {
90
+ cleanup();
91
+ getDefaultQueryClient().removeQueries({ queryKey: ["provider-probe"] });
92
+ setQueryClient();
93
+ setI18n();
94
+ vi.restoreAllMocks();
95
+ });
96
+
97
+ describe("AgentServerUIProviders", () => {
98
+ it("exports and uses the default query client and i18n instances when props are omitted", async () => {
99
+ const defaultI18n = getDefaultI18n();
100
+
101
+ defaultI18n.addResourceBundle(
102
+ "en",
103
+ OPENHANDS_I18N_NAMESPACE,
104
+ { PROVIDER$LABEL: "Default provider" },
105
+ true,
106
+ true,
107
+ );
108
+ await defaultI18n.changeLanguage("en");
109
+
110
+ getDefaultQueryClient().setQueryData(["provider-probe"], "default-client");
111
+
112
+ render(
113
+ <AgentServerUIProviders>
114
+ <DefaultProbe />
115
+ </AgentServerUIProviders>,
116
+ );
117
+
118
+ expect(screen.getByTestId("query-client-kind")).toHaveTextContent(
119
+ "default",
120
+ );
121
+ expect(screen.getByTestId("query-client-value")).toHaveTextContent(
122
+ "default-client",
123
+ );
124
+
125
+ await waitFor(() => {
126
+ expect(
127
+ screen.getByTestId("imperative-translation-value"),
128
+ ).toHaveTextContent("Default provider");
129
+ });
130
+
131
+ expect(getQueryClient()).toBe(getDefaultQueryClient());
132
+ });
133
+
134
+ it("injects a custom query client and i18n instance without conflicting with imperative callers", async () => {
135
+ const customQueryClient = new QueryClient({
136
+ defaultOptions: {
137
+ queries: { retry: false },
138
+ },
139
+ });
140
+ const customI18n = await createTestI18n("Custom provider");
141
+
142
+ customQueryClient.setQueryData(["provider-probe"], "custom-client");
143
+
144
+ const view = render(
145
+ <AgentServerUIProviders queryClient={customQueryClient} i18n={customI18n}>
146
+ <CustomProbe />
147
+ </AgentServerUIProviders>,
148
+ );
149
+
150
+ expect(screen.getByTestId("query-client-kind")).toHaveTextContent("custom");
151
+ expect(screen.getByTestId("query-client-value")).toHaveTextContent(
152
+ "custom-client",
153
+ );
154
+
155
+ await waitFor(() => {
156
+ expect(screen.getByTestId("translation-value")).toHaveTextContent(
157
+ "Custom provider",
158
+ );
159
+ expect(
160
+ screen.getByTestId("imperative-translation-value"),
161
+ ).toHaveTextContent("Custom provider");
162
+ });
163
+
164
+ expect(getQueryClient()).toBe(customQueryClient);
165
+ expect(getI18n()).toBe(customI18n);
166
+
167
+ view.unmount();
168
+
169
+ expect(getQueryClient()).toBe(getDefaultQueryClient());
170
+ expect(getI18n()).toBe(getDefaultI18n());
171
+ });
172
+
173
+ it("passes disabled and runtime analytics configuration to TelemetryProvider", () => {
174
+ telemetryProviderMock.mockClear();
175
+
176
+ const noAnalyticsView = render(
177
+ <AgentServerUIProviders>
178
+ <div data-testid="child">child</div>
179
+ </AgentServerUIProviders>,
180
+ );
181
+
182
+ expect(screen.getByTestId("child")).toHaveTextContent("child");
183
+ expect(telemetryProviderMock).toHaveBeenCalledWith(
184
+ expect.objectContaining({ config: false }),
185
+ );
186
+
187
+ noAnalyticsView.unmount();
188
+ telemetryProviderMock.mockClear();
189
+
190
+ const analytics = {
191
+ provider: "posthog" as const,
192
+ apiKey: "phc_embedded",
193
+ apiHost: "https://events.example.com",
194
+ uiHost: "https://posthog.example.com",
195
+ };
196
+
197
+ render(
198
+ <AgentServerUIProviders analytics={analytics}>
199
+ <div data-testid="child-with-analytics">child</div>
200
+ </AgentServerUIProviders>,
201
+ );
202
+
203
+ expect(telemetryProviderMock).toHaveBeenCalledWith(
204
+ expect.objectContaining({
205
+ config: {
206
+ apiKey: analytics.apiKey,
207
+ apiHost: analytics.apiHost,
208
+ uiHost: analytics.uiHost,
209
+ },
210
+ }),
211
+ );
212
+ });
213
+
214
+ it("wraps children in a scoped, customizable style root by default", () => {
215
+ const { unmount } = render(
216
+ <AgentServerUIProviders
217
+ contentClassName="min-h-screen"
218
+ styleOverrides={{ "--oh-color-base": "#010203" }}
219
+ >
220
+ <div data-testid="styled-child">child</div>
221
+ </AgentServerUIProviders>,
222
+ );
223
+
224
+ const scopeRoot = document.querySelector<HTMLDivElement>(
225
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
226
+ );
227
+
228
+ expect(scopeRoot).toBeInTheDocument();
229
+ expect(scopeRoot?.style.getPropertyValue("--oh-color-base")).toBe(
230
+ "#010203",
231
+ );
232
+
233
+ const themedContainer =
234
+ scopeRoot?.firstElementChild as HTMLDivElement | null;
235
+ expect(themedContainer).toHaveAttribute("data-theme", "dark");
236
+ expect(themedContainer).toHaveClass("dark", "min-h-screen");
237
+ expect(themedContainer).toContainElement(
238
+ screen.getByTestId("styled-child"),
239
+ );
240
+
241
+ unmount();
242
+
243
+ render(
244
+ <AgentServerUIProviders withStyleRoot={false}>
245
+ <div data-testid="unstyled-child">child</div>
246
+ </AgentServerUIProviders>,
247
+ );
248
+
249
+ expect(document.querySelector(AGENT_SERVER_UI_SCOPE_SELECTOR)).toBeNull();
250
+ });
251
+
252
+ it("exposes a standalone style root for host-controlled customization", () => {
253
+ render(
254
+ <AgentServerUIRoot
255
+ className="outer-shell"
256
+ contentClassName="inner-shell"
257
+ theme="light"
258
+ styleOverrides={{ "--oh-color-primary": "#abcdef" }}
259
+ >
260
+ <div data-testid="root-child">child</div>
261
+ </AgentServerUIRoot>,
262
+ );
263
+
264
+ const scopeRoot = document.querySelector<HTMLDivElement>(
265
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
266
+ );
267
+
268
+ expect(scopeRoot).toHaveClass("outer-shell");
269
+ expect(scopeRoot?.style.getPropertyValue("--oh-color-primary")).toBe(
270
+ "#abcdef",
271
+ );
272
+
273
+ const themedContainer =
274
+ scopeRoot?.firstElementChild as HTMLDivElement | null;
275
+ expect(themedContainer).toHaveAttribute("data-theme", "light");
276
+ expect(themedContainer).toHaveClass("light", "inner-shell");
277
+ expect(themedContainer).toContainElement(screen.getByTestId("root-child"));
278
+ });
279
+ });
__tests__/agent-server-ui-style-scope.test.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
4
+ transformAgentServerUISelector,
5
+ } from "#/styles/agent-server-ui-style-scope";
6
+
7
+ describe("transformAgentServerUISelector", () => {
8
+ it("prefixes ordinary selectors under the scoped root", () => {
9
+ expect(
10
+ transformAgentServerUISelector(
11
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
12
+ ".button-base",
13
+ `${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`,
14
+ ),
15
+ ).toBe(`${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`);
16
+ });
17
+
18
+ it("replaces :host selectors with the scoped root", () => {
19
+ expect(
20
+ transformAgentServerUISelector(
21
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
22
+ ":host",
23
+ `${AGENT_SERVER_UI_SCOPE_SELECTOR} :host`,
24
+ ),
25
+ ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR);
26
+ });
27
+
28
+ it.each([":root", "body", "html"])(
29
+ "maps %s selectors directly to the scoped root",
30
+ (selector) => {
31
+ expect(
32
+ transformAgentServerUISelector(
33
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
34
+ selector,
35
+ `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`,
36
+ ),
37
+ ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR);
38
+ },
39
+ );
40
+
41
+ it("does not double-prefix selectors that are already scoped", () => {
42
+ const selector = `${AGENT_SERVER_UI_SCOPE_SELECTOR} .xterm`;
43
+
44
+ expect(
45
+ transformAgentServerUISelector(
46
+ AGENT_SERVER_UI_SCOPE_SELECTOR,
47
+ selector,
48
+ `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`,
49
+ ),
50
+ ).toBe(selector);
51
+ });
52
+ });
__tests__/build-websocket-url.test.ts ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { buildWebSocketUrl } from "#/utils/websocket-url";
3
+
4
+ describe("buildWebSocketUrl", () => {
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ describe("Basic URL construction", () => {
10
+ it("should build WebSocket URL with conversation ID and URL", () => {
11
+ vi.stubGlobal("location", {
12
+ protocol: "http:",
13
+ host: "localhost:3000",
14
+ });
15
+
16
+ const result = buildWebSocketUrl(
17
+ "conv-123",
18
+ "http://localhost:8080/api/conversations/conv-123",
19
+ );
20
+
21
+ expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
22
+ });
23
+
24
+ it("should use wss:// protocol when window.location.protocol is https:", () => {
25
+ vi.stubGlobal("location", {
26
+ protocol: "https:",
27
+ host: "localhost:3000",
28
+ });
29
+
30
+ const result = buildWebSocketUrl(
31
+ "conv-123",
32
+ "https://example.com:8080/api/conversations/conv-123",
33
+ );
34
+
35
+ expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
36
+ });
37
+
38
+ it("should use ws:// for external HTTP hosts when page is HTTP", () => {
39
+ vi.stubGlobal("location", {
40
+ protocol: "http:",
41
+ host: "localhost:3000",
42
+ });
43
+
44
+ const result = buildWebSocketUrl(
45
+ "conv-456",
46
+ "http://agent-server.com:9000/api/conversations/conv-456",
47
+ );
48
+
49
+ expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
50
+ });
51
+
52
+ it("should use wss:// for external HTTPS hosts when page is HTTP", () => {
53
+ vi.stubGlobal("location", {
54
+ protocol: "http:",
55
+ host: "localhost:3000",
56
+ });
57
+
58
+ const result = buildWebSocketUrl(
59
+ "conv-456",
60
+ "https://agent-server.com:9000/api/conversations/conv-456",
61
+ );
62
+
63
+ expect(result).toBe(
64
+ "wss://agent-server.com:9000/sockets/events/conv-456",
65
+ );
66
+ });
67
+
68
+ it("should use ws:// for localhost when page is HTTP", () => {
69
+ vi.stubGlobal("location", {
70
+ protocol: "http:",
71
+ host: "localhost:3000",
72
+ });
73
+
74
+ const result = buildWebSocketUrl(
75
+ "conv-456",
76
+ "http://127.0.0.1:9000/api/conversations/conv-456",
77
+ );
78
+
79
+ expect(result).toBe("ws://127.0.0.1:9000/sockets/events/conv-456");
80
+ });
81
+ });
82
+
83
+ describe("Query parameters handling", () => {
84
+ beforeEach(() => {
85
+ vi.stubGlobal("location", {
86
+ protocol: "http:",
87
+ host: "localhost:3000",
88
+ });
89
+ });
90
+
91
+ it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
92
+ const result = buildWebSocketUrl(
93
+ "conv-123",
94
+ "http://localhost:8080/api/conversations/conv-123",
95
+ );
96
+
97
+ expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
98
+ expect(result).not.toContain("?");
99
+ expect(result).not.toContain("session_api_key");
100
+ });
101
+ });
102
+
103
+ describe("Fallback to window.location.host", () => {
104
+ it("should use window.location.host when conversation URL is null", () => {
105
+ vi.stubGlobal("location", {
106
+ protocol: "http:",
107
+ host: "fallback-host:4000",
108
+ });
109
+
110
+ const result = buildWebSocketUrl("conv-123", null);
111
+
112
+ expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
113
+ });
114
+
115
+ it("should use window.location.host when conversation URL is undefined", () => {
116
+ vi.stubGlobal("location", {
117
+ protocol: "http:",
118
+ host: "fallback-host:4000",
119
+ });
120
+
121
+ const result = buildWebSocketUrl("conv-123", undefined);
122
+
123
+ expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
124
+ });
125
+
126
+ it("should use window.location.host when conversation URL is relative path", () => {
127
+ vi.stubGlobal("location", {
128
+ protocol: "http:",
129
+ host: "fallback-host:4000",
130
+ });
131
+
132
+ const result = buildWebSocketUrl(
133
+ "conv-123",
134
+ "/api/conversations/conv-123",
135
+ );
136
+
137
+ expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
138
+ });
139
+
140
+ it("should use window.location.host when conversation URL is invalid", () => {
141
+ vi.stubGlobal("location", {
142
+ protocol: "http:",
143
+ host: "fallback-host:4000",
144
+ });
145
+
146
+ const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
147
+
148
+ expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
149
+ });
150
+ });
151
+
152
+ describe("Edge cases", () => {
153
+ beforeEach(() => {
154
+ vi.stubGlobal("location", {
155
+ protocol: "http:",
156
+ host: "localhost:3000",
157
+ });
158
+ });
159
+
160
+ it("should return null when conversationId is undefined", () => {
161
+ const result = buildWebSocketUrl(
162
+ undefined,
163
+ "http://localhost:8080/api/conversations/conv-123",
164
+ );
165
+
166
+ expect(result).toBeNull();
167
+ });
168
+
169
+ it("should return null when conversationId is empty string", () => {
170
+ const result = buildWebSocketUrl(
171
+ "",
172
+ "http://localhost:8080/api/conversations/conv-123",
173
+ );
174
+
175
+ expect(result).toBeNull();
176
+ });
177
+
178
+ it("should handle conversation URLs with non-standard ports on external hosts", () => {
179
+ const result = buildWebSocketUrl(
180
+ "conv-123",
181
+ "http://example.com:12345/api/conversations/conv-123",
182
+ );
183
+
184
+ expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
185
+ });
186
+
187
+ it("should handle conversation URLs without port (default port) on external hosts", () => {
188
+ const result = buildWebSocketUrl(
189
+ "conv-123",
190
+ "http://example.com/api/conversations/conv-123",
191
+ );
192
+
193
+ expect(result).toBe("ws://example.com/sockets/events/conv-123");
194
+ });
195
+
196
+ it("should handle conversation IDs with special characters", () => {
197
+ const result = buildWebSocketUrl(
198
+ "conv-123-abc_def",
199
+ "http://localhost:8080/api/conversations/conv-123-abc_def",
200
+ );
201
+
202
+ expect(result).toBe(
203
+ "ws://localhost:8080/sockets/events/conv-123-abc_def",
204
+ );
205
+ });
206
+
207
+ it("should build URL without query parameters", () => {
208
+ const result = buildWebSocketUrl(
209
+ "conv-123",
210
+ "http://localhost:8080/api/conversations/conv-123",
211
+ );
212
+
213
+ expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
214
+ expect(result).not.toContain("?");
215
+ });
216
+ });
217
+
218
+ describe("protocol selection for external hosts", () => {
219
+ it("should use wss:// for HTTPS prod-runtime.all-hands.dev domains", () => {
220
+ vi.stubGlobal("location", {
221
+ protocol: "http:",
222
+ host: "localhost:8000",
223
+ });
224
+
225
+ // Use obviously fake IDs that follow the format pattern
226
+ const fakeConversationId = "00000000deadbeef0000000000000000";
227
+ const fakeRuntimeHost = "faketesthost.prod-runtime.all-hands.dev";
228
+
229
+ const result = buildWebSocketUrl(
230
+ fakeConversationId,
231
+ `https://${fakeRuntimeHost}/api/conversations/${fakeConversationId}`,
232
+ );
233
+
234
+ expect(result).toBe(
235
+ `wss://${fakeRuntimeHost}/sockets/events/${fakeConversationId}`,
236
+ );
237
+ });
238
+
239
+ it("should use ws:// for ::1 (IPv6 localhost)", () => {
240
+ vi.stubGlobal("location", {
241
+ protocol: "http:",
242
+ host: "[::1]:3000",
243
+ });
244
+
245
+ const result = buildWebSocketUrl(
246
+ "test-conv-ipv6",
247
+ "http://[::1]:8080/api/conversations/test-conv-ipv6",
248
+ );
249
+
250
+ expect(result).toBe("ws://[::1]:8080/sockets/events/test-conv-ipv6");
251
+ });
252
+
253
+ it("should use ws:// for .localhost subdomains", () => {
254
+ vi.stubGlobal("location", {
255
+ protocol: "http:",
256
+ host: "app.localhost:3000",
257
+ });
258
+
259
+ const result = buildWebSocketUrl(
260
+ "test-conv-localhost-subdomain",
261
+ "http://api.localhost:8080/api/conversations/test-conv-localhost-subdomain",
262
+ );
263
+
264
+ expect(result).toBe(
265
+ "ws://api.localhost:8080/sockets/events/test-conv-localhost-subdomain",
266
+ );
267
+ });
268
+ });
269
+ });
__tests__/conversation-local-storage.test.ts ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ clearConversationLocalStorage,
4
+ getConversationState,
5
+ isTaskConversationId,
6
+ setConversationState,
7
+ LOCAL_STORAGE_KEYS,
8
+ } from "#/utils/conversation-local-storage";
9
+
10
+ describe("conversation localStorage utilities", () => {
11
+ beforeEach(() => {
12
+ localStorage.clear();
13
+ });
14
+
15
+ describe("isTaskConversationId", () => {
16
+ it("returns true for IDs starting with task-", () => {
17
+ expect(isTaskConversationId("task-abc-123")).toBe(true);
18
+ expect(isTaskConversationId("task-")).toBe(true);
19
+ });
20
+
21
+ it("returns false for normal conversation IDs", () => {
22
+ expect(isTaskConversationId("conv-123")).toBe(false);
23
+ expect(isTaskConversationId("abc")).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe("getConversationState", () => {
28
+ it("returns default state including conversationMode for task IDs without reading localStorage", () => {
29
+ const state = getConversationState("task-uuid-123");
30
+
31
+ expect(state.conversationMode).toBe("code");
32
+ expect(state.selectedTab).toBe("files");
33
+ expect(
34
+ localStorage.getItem(
35
+ `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-uuid-123`,
36
+ ),
37
+ ).toBeNull();
38
+ });
39
+
40
+ it("returns merged state from localStorage for real conversation ID including conversationMode", () => {
41
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-1`;
42
+ localStorage.setItem(
43
+ key,
44
+ JSON.stringify({ conversationMode: "plan", selectedTab: "terminal" }),
45
+ );
46
+
47
+ const state = getConversationState("conv-1");
48
+
49
+ expect(state.conversationMode).toBe("plan");
50
+ expect(state.selectedTab).toBe("terminal");
51
+ });
52
+
53
+ it("round-trips rightPanelShown through localStorage", () => {
54
+ const conversationId = "conv-right-panel";
55
+ setConversationState(conversationId, {
56
+ selectedTab: "terminal",
57
+ rightPanelShown: true,
58
+ unpinnedTabs: ["browser"],
59
+ });
60
+
61
+ const state = getConversationState(conversationId);
62
+
63
+ expect(state.selectedTab).toBe("terminal");
64
+ expect(state.unpinnedTabs).toEqual(["browser"]);
65
+ expect(state.rightPanelShown).toBe(true);
66
+ });
67
+
68
+ it("defaults rightPanelShown to false and drops corrupt values", () => {
69
+ expect(getConversationState("conv-right-panel-default").rightPanelShown).toBe(
70
+ false,
71
+ );
72
+
73
+ const conversationId = "conv-right-panel-corrupt";
74
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
75
+ localStorage.setItem(
76
+ key,
77
+ JSON.stringify({
78
+ selectedTab: "terminal",
79
+ rightPanelShown: "yes",
80
+ }),
81
+ );
82
+
83
+ expect(getConversationState(conversationId).rightPanelShown).toBe(false);
84
+ });
85
+
86
+ it("returns default state when key is missing or invalid", () => {
87
+ expect(getConversationState("conv-missing").conversationMode).toBe(
88
+ "code",
89
+ );
90
+
91
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-bad`;
92
+ localStorage.setItem(key, "not json");
93
+ expect(getConversationState("conv-bad").conversationMode).toBe("code");
94
+ });
95
+ });
96
+
97
+ describe("setConversationState", () => {
98
+ it("does not persist when conversationId is a task ID", () => {
99
+ setConversationState("task-xyz", { conversationMode: "plan" });
100
+
101
+ expect(
102
+ localStorage.getItem(
103
+ `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-xyz`,
104
+ ),
105
+ ).toBeNull();
106
+ });
107
+
108
+ it("persists conversationMode for real conversation ID and getConversationState returns it", () => {
109
+ setConversationState("conv-2", { conversationMode: "plan" });
110
+
111
+ const state = getConversationState("conv-2");
112
+ expect(state.conversationMode).toBe("plan");
113
+ });
114
+ });
115
+
116
+ describe("clearConversationLocalStorage", () => {
117
+ it("removes the consolidated conversation-state localStorage entry", () => {
118
+ const conversationId = "conv-123";
119
+
120
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
121
+ localStorage.setItem(
122
+ consolidatedKey,
123
+ JSON.stringify({
124
+ selectedTab: "editor",
125
+ unpinnedTabs: [],
126
+ }),
127
+ );
128
+
129
+ clearConversationLocalStorage(conversationId);
130
+
131
+ expect(localStorage.getItem(consolidatedKey)).toBeNull();
132
+ });
133
+
134
+ it("does not throw if conversation keys do not exist", () => {
135
+ expect(() => {
136
+ clearConversationLocalStorage("non-existent-id");
137
+ }).not.toThrow();
138
+ });
139
+ });
140
+
141
+ describe("getConversationState", () => {
142
+ it("returns default state with subConversationTaskId as null when no state exists", () => {
143
+ const conversationId = "conv-123";
144
+ const state = getConversationState(conversationId);
145
+
146
+ expect(state.subConversationTaskId).toBeNull();
147
+ expect(state.selectedTab).toBe("files");
148
+ expect(state.unpinnedTabs).toEqual([]);
149
+ expect(state.unpinnedOverviewSections).toEqual([]);
150
+ expect(state.unpinnedOverviewGitParts).toEqual([]);
151
+ });
152
+
153
+ it("persists and sanitizes unpinnedOverviewSections", () => {
154
+ const conversationId = "conv-overview-pins";
155
+ setConversationState(conversationId, {
156
+ unpinnedOverviewSections: ["skills", "not-a-section", "mcp", "workspace"],
157
+ });
158
+
159
+ const state = getConversationState(conversationId);
160
+ // Legacy section ids (mcp/skills/secrets/…) are dropped by the allowlist.
161
+ expect(state.unpinnedOverviewSections).toEqual(["workspace"]);
162
+ });
163
+
164
+ it("persists and sanitizes unpinnedOverviewGitParts", () => {
165
+ const conversationId = "conv-overview-git-pins";
166
+ setConversationState(conversationId, {
167
+ unpinnedOverviewGitParts: ["branch", "not-a-part", "issues"],
168
+ });
169
+
170
+ const state = getConversationState(conversationId);
171
+ // Legacy git part ids (issues) are dropped by the allowlist.
172
+ expect(state.unpinnedOverviewGitParts).toEqual(["branch"]);
173
+ });
174
+
175
+ it("retrieves subConversationTaskId from localStorage when it exists", () => {
176
+ const conversationId = "conv-123";
177
+ const taskId = "task-uuid-123";
178
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
179
+
180
+ localStorage.setItem(
181
+ consolidatedKey,
182
+ JSON.stringify({
183
+ selectedTab: "editor",
184
+ unpinnedTabs: [],
185
+ subConversationTaskId: taskId,
186
+ }),
187
+ );
188
+
189
+ const state = getConversationState(conversationId);
190
+
191
+ expect(state.subConversationTaskId).toBe(taskId);
192
+ });
193
+
194
+ it("merges stored state with defaults when partial state exists", () => {
195
+ const conversationId = "conv-123";
196
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
197
+
198
+ localStorage.setItem(
199
+ consolidatedKey,
200
+ JSON.stringify({
201
+ subConversationTaskId: "task-123",
202
+ }),
203
+ );
204
+
205
+ const state = getConversationState(conversationId);
206
+
207
+ expect(state.subConversationTaskId).toBe("task-123");
208
+ expect(state.selectedTab).toBe("files");
209
+ expect(state.unpinnedTabs).toEqual([]);
210
+ });
211
+
212
+ it("falls back to the default tab when stored selectedTab is no longer valid", () => {
213
+ const conversationId = "conv-123";
214
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
215
+
216
+ // Persisted from a previous app version where "editor" was a tab.
217
+ localStorage.setItem(
218
+ consolidatedKey,
219
+ JSON.stringify({
220
+ selectedTab: "editor",
221
+ unpinnedTabs: [],
222
+ }),
223
+ );
224
+
225
+ const state = getConversationState(conversationId);
226
+
227
+ expect(state.selectedTab).toBe("files");
228
+ });
229
+
230
+ it("migrates a stored Diffs (changes) tab selection to Commits", () => {
231
+ const conversationId = "conv-123";
232
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
233
+
234
+ localStorage.setItem(
235
+ consolidatedKey,
236
+ JSON.stringify({
237
+ selectedTab: "changes",
238
+ unpinnedTabs: [],
239
+ }),
240
+ );
241
+
242
+ const state = getConversationState(conversationId);
243
+
244
+ expect(state.selectedTab).toBe("commits");
245
+ });
246
+
247
+ it("filters obsolete tabs out of stored unpinnedTabs (editor / served / app / changes)", () => {
248
+ // Returning users may have unpinned the now-removed Editor, Served,
249
+ // App, or Diffs (`changes`) tabs in a previous version. Those names
250
+ // should not survive the read β€” otherwise they linger forever in
251
+ // localStorage since the UI has no way to surface them again to be
252
+ // re-pinned.
253
+ const conversationId = "conv-123";
254
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
255
+
256
+ localStorage.setItem(
257
+ consolidatedKey,
258
+ JSON.stringify({
259
+ selectedTab: "files",
260
+ unpinnedTabs: ["editor", "changes", "served", "app", "terminal"],
261
+ }),
262
+ );
263
+
264
+ const state = getConversationState(conversationId);
265
+
266
+ // Obsolete names are dropped; still-valid `terminal` stays.
267
+ expect(state.unpinnedTabs).toEqual(["terminal"]);
268
+ });
269
+ });
270
+
271
+ describe("setConversationState", () => {
272
+ it("persists subConversationTaskId to localStorage", () => {
273
+ const conversationId = "conv-123";
274
+ const taskId = "task-uuid-456";
275
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
276
+
277
+ setConversationState(conversationId, {
278
+ subConversationTaskId: taskId,
279
+ });
280
+
281
+ const stored = localStorage.getItem(consolidatedKey);
282
+ expect(stored).not.toBeNull();
283
+
284
+ const parsed = JSON.parse(stored!);
285
+ expect(parsed.subConversationTaskId).toBe(taskId);
286
+ });
287
+
288
+ it("merges subConversationTaskId with existing state", () => {
289
+ const conversationId = "conv-123";
290
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
291
+
292
+ // Set initial state
293
+ localStorage.setItem(
294
+ consolidatedKey,
295
+ JSON.stringify({
296
+ selectedTab: "browser",
297
+ unpinnedTabs: ["tab-1"],
298
+ subConversationTaskId: "old-task-id",
299
+ }),
300
+ );
301
+
302
+ // Update only subConversationTaskId
303
+ setConversationState(conversationId, {
304
+ subConversationTaskId: "new-task-id",
305
+ });
306
+
307
+ const stored = localStorage.getItem(consolidatedKey);
308
+ const parsed = JSON.parse(stored!);
309
+
310
+ expect(parsed.subConversationTaskId).toBe("new-task-id");
311
+ expect(parsed.selectedTab).toBe("browser");
312
+ expect(parsed.unpinnedTabs).toEqual(["tab-1"]);
313
+ });
314
+
315
+ it("clears subConversationTaskId when set to null", () => {
316
+ const conversationId = "conv-123";
317
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
318
+
319
+ // Set initial state with task ID
320
+ localStorage.setItem(
321
+ consolidatedKey,
322
+ JSON.stringify({
323
+ subConversationTaskId: "task-123",
324
+ }),
325
+ );
326
+
327
+ // Clear the task ID
328
+ setConversationState(conversationId, {
329
+ subConversationTaskId: null,
330
+ });
331
+
332
+ const stored = localStorage.getItem(consolidatedKey);
333
+ const parsed = JSON.parse(stored!);
334
+
335
+ expect(parsed.subConversationTaskId).toBeNull();
336
+ });
337
+ });
338
+
339
+ describe("draftMessage persistence", () => {
340
+ describe("getConversationState", () => {
341
+ it("returns default draftMessage as null when no state exists", () => {
342
+ // Arrange
343
+ const conversationId = "conv-draft-1";
344
+
345
+ // Act
346
+ const state = getConversationState(conversationId);
347
+
348
+ // Assert
349
+ expect(state.draftMessage).toBeNull();
350
+ });
351
+
352
+ it("retrieves draftMessage from localStorage when it exists", () => {
353
+ // Arrange
354
+ const conversationId = "conv-draft-2";
355
+ const draftText = "This is my saved draft message";
356
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
357
+
358
+ localStorage.setItem(
359
+ consolidatedKey,
360
+ JSON.stringify({
361
+ draftMessage: draftText,
362
+ }),
363
+ );
364
+
365
+ // Act
366
+ const state = getConversationState(conversationId);
367
+
368
+ // Assert
369
+ expect(state.draftMessage).toBe(draftText);
370
+ });
371
+
372
+ it("returns null draftMessage for task conversation IDs (not persisted)", () => {
373
+ // Arrange
374
+ const taskId = "task-uuid-123";
375
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
376
+
377
+ // Even if somehow there's data in localStorage for a task ID
378
+ localStorage.setItem(
379
+ consolidatedKey,
380
+ JSON.stringify({
381
+ draftMessage: "Should not be returned",
382
+ }),
383
+ );
384
+
385
+ // Act
386
+ const state = getConversationState(taskId);
387
+
388
+ // Assert - should return default state, not the stored value
389
+ expect(state.draftMessage).toBeNull();
390
+ });
391
+ });
392
+
393
+ describe("setConversationState", () => {
394
+ it("persists draftMessage to localStorage", () => {
395
+ // Arrange
396
+ const conversationId = "conv-draft-3";
397
+ const draftText = "New draft message to save";
398
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
399
+
400
+ // Act
401
+ setConversationState(conversationId, {
402
+ draftMessage: draftText,
403
+ });
404
+
405
+ // Assert
406
+ const stored = localStorage.getItem(consolidatedKey);
407
+ expect(stored).not.toBeNull();
408
+ const parsed = JSON.parse(stored!);
409
+ expect(parsed.draftMessage).toBe(draftText);
410
+ });
411
+
412
+ it("does not persist draftMessage for task conversation IDs", () => {
413
+ // Arrange
414
+ const taskId = "task-draft-xyz";
415
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
416
+
417
+ // Act
418
+ setConversationState(taskId, {
419
+ draftMessage: "Draft for task ID",
420
+ });
421
+
422
+ // Assert - nothing should be stored
423
+ expect(localStorage.getItem(consolidatedKey)).toBeNull();
424
+ });
425
+
426
+ it("merges draftMessage with existing state without overwriting other fields", () => {
427
+ // Arrange
428
+ const conversationId = "conv-draft-4";
429
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
430
+
431
+ localStorage.setItem(
432
+ consolidatedKey,
433
+ JSON.stringify({
434
+ selectedTab: "terminal",
435
+ unpinnedTabs: ["tab-1", "tab-2"],
436
+ conversationMode: "plan",
437
+ subConversationTaskId: "task-123",
438
+ }),
439
+ );
440
+
441
+ // Act
442
+ setConversationState(conversationId, {
443
+ draftMessage: "Updated draft",
444
+ });
445
+
446
+ // Assert
447
+ const stored = localStorage.getItem(consolidatedKey);
448
+ const parsed = JSON.parse(stored!);
449
+
450
+ expect(parsed.draftMessage).toBe("Updated draft");
451
+ expect(parsed.selectedTab).toBe("terminal");
452
+ expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]);
453
+ expect(parsed.conversationMode).toBe("plan");
454
+ expect(parsed.subConversationTaskId).toBe("task-123");
455
+ });
456
+
457
+ it("clears draftMessage when set to null", () => {
458
+ // Arrange
459
+ const conversationId = "conv-draft-5";
460
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
461
+
462
+ localStorage.setItem(
463
+ consolidatedKey,
464
+ JSON.stringify({
465
+ draftMessage: "Existing draft",
466
+ }),
467
+ );
468
+
469
+ // Act
470
+ setConversationState(conversationId, {
471
+ draftMessage: null,
472
+ });
473
+
474
+ // Assert
475
+ const stored = localStorage.getItem(consolidatedKey);
476
+ const parsed = JSON.parse(stored!);
477
+ expect(parsed.draftMessage).toBeNull();
478
+ });
479
+
480
+ it("clears draftMessage when set to empty string (stored as empty string)", () => {
481
+ // Arrange
482
+ const conversationId = "conv-draft-6";
483
+ const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
484
+
485
+ localStorage.setItem(
486
+ consolidatedKey,
487
+ JSON.stringify({
488
+ draftMessage: "Existing draft",
489
+ }),
490
+ );
491
+
492
+ // Act
493
+ setConversationState(conversationId, {
494
+ draftMessage: "",
495
+ });
496
+
497
+ // Assert
498
+ const stored = localStorage.getItem(consolidatedKey);
499
+ const parsed = JSON.parse(stored!);
500
+ expect(parsed.draftMessage).toBe("");
501
+ });
502
+ });
503
+
504
+ describe("conversation-specific draft isolation", () => {
505
+ it("stores drafts separately for different conversations", () => {
506
+ // Arrange
507
+ const convA = "conv-A";
508
+ const convB = "conv-B";
509
+ const draftA = "Draft for conversation A";
510
+ const draftB = "Draft for conversation B";
511
+
512
+ // Act
513
+ setConversationState(convA, { draftMessage: draftA });
514
+ setConversationState(convB, { draftMessage: draftB });
515
+
516
+ // Assert
517
+ const stateA = getConversationState(convA);
518
+ const stateB = getConversationState(convB);
519
+
520
+ expect(stateA.draftMessage).toBe(draftA);
521
+ expect(stateB.draftMessage).toBe(draftB);
522
+ });
523
+
524
+ it("updating one conversation draft does not affect another", () => {
525
+ // Arrange
526
+ const convA = "conv-isolated-A";
527
+ const convB = "conv-isolated-B";
528
+
529
+ setConversationState(convA, { draftMessage: "Original draft A" });
530
+ setConversationState(convB, { draftMessage: "Original draft B" });
531
+
532
+ // Act - update only conversation A
533
+ setConversationState(convA, { draftMessage: "Updated draft A" });
534
+
535
+ // Assert - conversation B should be unchanged
536
+ const stateA = getConversationState(convA);
537
+ const stateB = getConversationState(convB);
538
+
539
+ expect(stateA.draftMessage).toBe("Updated draft A");
540
+ expect(stateB.draftMessage).toBe("Original draft B");
541
+ });
542
+
543
+ it("clearing one conversation draft does not affect another", () => {
544
+ // Arrange
545
+ const convA = "conv-clear-A";
546
+ const convB = "conv-clear-B";
547
+
548
+ setConversationState(convA, { draftMessage: "Draft A" });
549
+ setConversationState(convB, { draftMessage: "Draft B" });
550
+
551
+ // Act - clear draft for conversation A
552
+ setConversationState(convA, { draftMessage: null });
553
+
554
+ // Assert
555
+ const stateA = getConversationState(convA);
556
+ const stateB = getConversationState(convB);
557
+
558
+ expect(stateA.draftMessage).toBeNull();
559
+ expect(stateB.draftMessage).toBe("Draft B");
560
+ });
561
+ });
562
+ });
563
+
564
+ describe("filesTabDiffView preference", () => {
565
+ it("preserves filesTabDiffView from stored blobs on read", () => {
566
+ const conversationId = "files-diff-legacy";
567
+ localStorage.setItem(
568
+ `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`,
569
+ JSON.stringify({
570
+ selectedTab: "files",
571
+ filesTabDiffView: true,
572
+ }),
573
+ );
574
+
575
+ const state = getConversationState(conversationId);
576
+ expect(state.filesTabDiffView).toBe(true);
577
+ });
578
+ });
579
+
580
+ describe("filesTabContentViewMode persistence", () => {
581
+ // The rich/plain toggle for the file content viewer also persists
582
+ // per conversation. Default is "rich" β€” verified explicitly here so
583
+ // a careless change to the default field initializer doesn't slip
584
+ // through unnoticed (it would flip every existing user from rich to
585
+ // plain after deploy).
586
+
587
+ it("defaults to 'rich' when nothing is stored", () => {
588
+ const state = getConversationState("files-view-conv-1");
589
+ expect(state.filesTabContentViewMode).toBe("rich");
590
+ });
591
+
592
+ it("round-trips 'plain' through localStorage", () => {
593
+ const conversationId = "files-view-conv-2";
594
+ setConversationState(conversationId, {
595
+ filesTabContentViewMode: "plain",
596
+ });
597
+
598
+ expect(getConversationState(conversationId).filesTabContentViewMode).toBe(
599
+ "plain",
600
+ );
601
+
602
+ const raw = localStorage.getItem(
603
+ `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`,
604
+ );
605
+ expect(JSON.parse(raw as string).filesTabContentViewMode).toBe("plain");
606
+ });
607
+
608
+ it("round-trips 'rich' through localStorage (explicit save, not default)", () => {
609
+ const conversationId = "files-view-conv-3";
610
+ setConversationState(conversationId, {
611
+ filesTabContentViewMode: "rich",
612
+ });
613
+
614
+ expect(getConversationState(conversationId).filesTabContentViewMode).toBe(
615
+ "rich",
616
+ );
617
+ });
618
+
619
+ it("is isolated per conversation", () => {
620
+ setConversationState("files-view-convA", {
621
+ filesTabContentViewMode: "plain",
622
+ });
623
+ setConversationState("files-view-convB", {
624
+ filesTabContentViewMode: "rich",
625
+ });
626
+
627
+ expect(
628
+ getConversationState("files-view-convA").filesTabContentViewMode,
629
+ ).toBe("plain");
630
+ expect(
631
+ getConversationState("files-view-convB").filesTabContentViewMode,
632
+ ).toBe("rich");
633
+ });
634
+
635
+ it("falls back to the 'rich' default when localStorage holds a junk value", () => {
636
+ // A corrupted entry (older build with a renamed mode, a hand-edited
637
+ // value in devtools, …) must not leak through to the ViewMode-typed
638
+ // consumer β€” the sanitizer drops the bad value so the merged result
639
+ // re-applies the typed default.
640
+ const conversationId = "files-view-corrupt";
641
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
642
+ localStorage.setItem(
643
+ key,
644
+ JSON.stringify({ filesTabContentViewMode: "fancy" }),
645
+ );
646
+
647
+ const state = getConversationState(conversationId);
648
+ expect(state.filesTabContentViewMode).toBe("rich");
649
+ });
650
+ });
651
+
652
+ describe("files tab open-state / tree persistence", () => {
653
+ it("defaults to an expanded tree and no open files", () => {
654
+ const state = getConversationState("files-open-defaults");
655
+ expect(state.filesTabTreeVisible).toBe(true);
656
+ expect(state.filesTabOpenPaths).toEqual([]);
657
+ expect(state.filesTabSelectedPath).toBeNull();
658
+ });
659
+
660
+ it("round-trips tree visibility and open tabs", () => {
661
+ const conversationId = "files-open-roundtrip";
662
+ setConversationState(conversationId, {
663
+ filesTabTreeVisible: false,
664
+ filesTabOpenPaths: ["README.md", "src/main.ts"],
665
+ filesTabSelectedPath: "src/main.ts",
666
+ });
667
+
668
+ const state = getConversationState(conversationId);
669
+ expect(state.filesTabTreeVisible).toBe(false);
670
+ expect(state.filesTabOpenPaths).toEqual(["README.md", "src/main.ts"]);
671
+ expect(state.filesTabSelectedPath).toBe("src/main.ts");
672
+ });
673
+
674
+ it("sanitizes corrupt open-state fields", () => {
675
+ const conversationId = "files-open-corrupt";
676
+ const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
677
+ localStorage.setItem(
678
+ key,
679
+ JSON.stringify({
680
+ filesTabTreeVisible: "yes",
681
+ filesTabOpenPaths: ["ok.ts", 12, "", null],
682
+ filesTabSelectedPath: { path: "nope" },
683
+ }),
684
+ );
685
+
686
+ const state = getConversationState(conversationId);
687
+ expect(state.filesTabTreeVisible).toBe(true);
688
+ expect(state.filesTabOpenPaths).toEqual(["ok.ts"]);
689
+ expect(state.filesTabSelectedPath).toBeNull();
690
+ });
691
+ });
692
+ });
__tests__/initial-query.test.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { useInitialQueryStore } from "../src/stores/initial-query-store";
3
+
4
+ describe("Initial Query Behavior", () => {
5
+ beforeEach(() => {
6
+ // Reset the store before each test
7
+ useInitialQueryStore.getState().reset();
8
+ });
9
+
10
+ it("should clear initial query when clearInitialPrompt is called", () => {
11
+ const { setInitialPrompt, clearInitialPrompt, initialPrompt } =
12
+ useInitialQueryStore.getState();
13
+
14
+ // Set up initial query in the store
15
+ setInitialPrompt("test query");
16
+ expect(useInitialQueryStore.getState().initialPrompt).toBe("test query");
17
+
18
+ // Clear the initial query
19
+ clearInitialPrompt();
20
+
21
+ // Verify initial query is cleared
22
+ expect(useInitialQueryStore.getState().initialPrompt).toBeNull();
23
+ });
24
+ });
__tests__/library-entrypoints.test.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as publicApi from "../src/index";
2
+ import * as browserApi from "../src/components/browser/index";
3
+ import * as conversationApi from "../src/components/conversation/index";
4
+ import * as filesApi from "../src/components/files/index";
5
+ import * as settingsApi from "../src/components/settings/index";
6
+ import * as sidebarApi from "../src/components/sidebar/index";
7
+ import * as terminalApi from "../src/components/terminal/index";
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ describe("library public entrypoints", () => {
11
+ it("re-exports the primary library surface from the root entry", () => {
12
+ expect(publicApi.ConversationView).toBeTypeOf("function");
13
+ expect(publicApi.ChatPanel).toBeTypeOf("function");
14
+ expect(publicApi.TerminalPanel).toBeTypeOf("function");
15
+ expect(publicApi.BrowserPanel).toBeTypeOf("function");
16
+ expect(publicApi.FileExplorer).toBeTypeOf("function");
17
+ expect(publicApi.SettingsPanel).toBeTypeOf("function");
18
+ expect(publicApi.LLMSettings).toBeTypeOf("function");
19
+ expect(publicApi.Sidebar).toBeTypeOf("function");
20
+ expect(publicApi.ConversationPanel).toBeTypeOf("function");
21
+ expect(publicApi.AgentServerUIProviders).toBeTypeOf("function");
22
+ expect(publicApi.AgentServerUIRoot).toBeTypeOf("function");
23
+ expect(publicApi.AGENT_SERVER_UI_SCOPE_SELECTOR).toBe(
24
+ "[data-agent-server-ui]",
25
+ );
26
+ expect(publicApi.AGENT_SERVER_UI_DEFAULT_THEME).toBe("dark");
27
+ });
28
+
29
+ it("keeps each component-domain barrel importable", () => {
30
+ expect(conversationApi.ConversationView).toBeTypeOf("function");
31
+ expect(conversationApi.ChatPanel).toBeTypeOf("function");
32
+ expect(browserApi.BrowserPanel).toBeTypeOf("function");
33
+ expect(terminalApi.TerminalPanel).toBeTypeOf("function");
34
+ expect(filesApi.FileExplorer).toBeTypeOf("function");
35
+ expect(settingsApi.SettingsPanel).toBeTypeOf("function");
36
+ expect(settingsApi.AppSettings).toBeTypeOf("function");
37
+ expect(settingsApi.LLMSettings).toBeTypeOf("function");
38
+ expect(settingsApi.MCPSettings).toBeTypeOf("function");
39
+ expect(settingsApi.SecretsSettings).toBeTypeOf("function");
40
+ expect(sidebarApi.Sidebar).toBeTypeOf("function");
41
+ expect(sidebarApi.ConversationPanel).toBeTypeOf("function");
42
+ });
43
+
44
+ it("no longer exposes the removed AgentServerSettings entry", () => {
45
+ expect(
46
+ (settingsApi as Record<string, unknown>).AgentServerSettings,
47
+ ).toBeUndefined();
48
+ });
49
+ });
__tests__/package-library.test.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @vitest-environment node
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const packageJson = JSON.parse(
8
+ readFileSync(resolve(__dirname, "../package.json"), "utf8"),
9
+ ) as {
10
+ name: string;
11
+ main: string;
12
+ module: string;
13
+ types: string;
14
+ exports: Record<string, unknown>;
15
+ scripts: Record<string, string>;
16
+ dependencies?: Record<string, string>;
17
+ devDependencies?: Record<string, string>;
18
+ };
19
+
20
+ const EXACT_SEMVER_PATTERN =
21
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
22
+
23
+ describe("package library metadata", () => {
24
+ const ALLOWED_STACK_PIN_DEPS = new Set([
25
+ "@openhands/extensions",
26
+ "@openhands/typescript-client",
27
+ ]);
28
+
29
+ it("publishes the agent-canvas package entrypoints", () => {
30
+ expect(packageJson.name).toBe("@openhands/agent-canvas");
31
+ expect(packageJson.main).toBe("./dist/index.cjs");
32
+ expect(packageJson.module).toBe("./dist/index.js");
33
+ expect(packageJson.types).toBe("./dist/index.d.ts");
34
+ expect(packageJson.exports).toMatchObject({
35
+ ".": {
36
+ types: "./dist/index.d.ts",
37
+ import: "./dist/index.js",
38
+ require: "./dist/index.cjs",
39
+ },
40
+ "./conversation": {
41
+ types: "./dist/components/conversation/index.d.ts",
42
+ import: "./dist/components/conversation/index.js",
43
+ require: "./dist/components/conversation/index.cjs",
44
+ },
45
+ "./settings": {
46
+ types: "./dist/components/settings/index.d.ts",
47
+ import: "./dist/components/settings/index.js",
48
+ require: "./dist/components/settings/index.cjs",
49
+ },
50
+ "./terminal": {
51
+ types: "./dist/components/terminal/index.d.ts",
52
+ import: "./dist/components/terminal/index.js",
53
+ require: "./dist/components/terminal/index.cjs",
54
+ },
55
+ "./i18n": {
56
+ types: "./dist/i18n/index.d.ts",
57
+ import: "./dist/i18n/index.js",
58
+ require: "./dist/i18n/index.cjs",
59
+ },
60
+ });
61
+ });
62
+
63
+ // Git dependencies break `npm install -g` because npm clones the repo and
64
+ // runs the prepare script without devDependencies. All packages should be
65
+ // referenced from a registry. @openhands/extensions is allowed until it is
66
+ // published to npm; @openhands/typescript-client is temporarily allowed while
67
+ // this stacked PR waits for the subscription client branch to merge/release.
68
+ // TODO(#917): remove @openhands/typescript-client exemption once
69
+ // OpenHands/typescript-client#178 merges and publishes to npm.
70
+ it("does not use git dependencies except approved stack pins", () => {
71
+ const GIT_DEP_PATTERN =
72
+ /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/;
73
+ const allDeps = {
74
+ ...packageJson.dependencies,
75
+ ...packageJson.devDependencies,
76
+ };
77
+
78
+ const violations = Object.entries(allDeps)
79
+ .filter(
80
+ ([name, version]) =>
81
+ GIT_DEP_PATTERN.test(version) && !ALLOWED_STACK_PIN_DEPS.has(name),
82
+ )
83
+ .map(([name, version]) => `${name}: ${version}`);
84
+
85
+ expect(violations).toEqual([]);
86
+ });
87
+
88
+ it("pins direct dependency versions exactly", () => {
89
+ const allDepsBySection = {
90
+ dependencies: packageJson.dependencies,
91
+ devDependencies: packageJson.devDependencies,
92
+ };
93
+
94
+ const violations = Object.entries(allDepsBySection).flatMap(
95
+ ([section, dependencies]) =>
96
+ Object.entries(dependencies ?? {})
97
+ .filter(
98
+ ([name, version]) =>
99
+ !EXACT_SEMVER_PATTERN.test(version) &&
100
+ !ALLOWED_STACK_PIN_DEPS.has(name),
101
+ )
102
+ .map(([name, version]) => `${section}.${name}: ${version}`),
103
+ );
104
+
105
+ expect(violations).toEqual([]);
106
+ });
107
+
108
+ it("prints startup guidance only for global installs", () => {
109
+ const runPostinstall = (isGlobal: boolean) => {
110
+ const env = { ...process.env };
111
+ if (isGlobal) {
112
+ env.npm_config_global = "true";
113
+ } else {
114
+ delete env.npm_config_global;
115
+ }
116
+
117
+ return spawnSync(packageJson.scripts.postinstall, {
118
+ encoding: "utf8",
119
+ env,
120
+ shell: true,
121
+ });
122
+ };
123
+
124
+ const dependencyInstall = runPostinstall(false);
125
+ const globalInstall = runPostinstall(true);
126
+
127
+ expect(dependencyInstall.status).toBe(0);
128
+ expect(dependencyInstall.stdout).toBe("");
129
+ expect(globalInstall.status).toBe(0);
130
+ expect(globalInstall.stdout).toContain("To start Agent Canvas, run:");
131
+ });
132
+
133
+ it("ships runtime logger dependencies for the published CLI", () => {
134
+ expect(packageJson.dependencies).toMatchObject({
135
+ winston: "3.19.0",
136
+ "winston-daily-rotate-file": "5.0.0",
137
+ });
138
+ expect(packageJson.devDependencies?.winston).toBeUndefined();
139
+ expect(
140
+ packageJson.devDependencies?.["winston-daily-rotate-file"],
141
+ ).toBeUndefined();
142
+ });
143
+
144
+ it("uses local dev commands without Docker", () => {
145
+ expect(packageJson.scripts.dev).toBe(
146
+ "node --env-file-if-exists=.env scripts/dev-with-automation.mjs",
147
+ );
148
+ expect(packageJson.scripts["dev:static"]).toBe(
149
+ "node --env-file-if-exists=.env scripts/dev-static.mjs",
150
+ );
151
+ expect(packageJson.scripts["dev:minimal"]).toBe(
152
+ "node --env-file-if-exists=.env scripts/dev-safe.mjs",
153
+ );
154
+ expect(packageJson.scripts["dev:docker"]).toBeUndefined();
155
+ expect(packageJson.scripts["dev:docker:dynamic"]).toBeUndefined();
156
+ expect(packageJson.scripts["dev:dangerously-dockerless"]).toBeUndefined();
157
+ });
158
+ });
__tests__/query-client-config.behavior.test.ts ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QueryClient } from "@tanstack/react-query";
2
+ import { AxiosError } from "axios";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createAgentServerQueryClient } from "#/query-client-config";
5
+ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
6
+ import type { Backend } from "#/api/backend-registry/types";
7
+ import {
8
+ __resetHealthStoreForTests,
9
+ getBackendHealthEntry,
10
+ recordBackendFailure,
11
+ } from "#/api/backend-registry/health-store";
12
+ import * as ToastHandlers from "#/utils/custom-toast-handlers";
13
+
14
+ interface ErrorOptions {
15
+ directStatus?: boolean;
16
+ message?: string;
17
+ status?: number;
18
+ url?: string;
19
+ }
20
+
21
+ function createAxiosError({
22
+ directStatus = false,
23
+ message = "Request failed",
24
+ status,
25
+ url,
26
+ }: ErrorOptions = {}) {
27
+ const error = new AxiosError(
28
+ message,
29
+ "ERR_BAD_REQUEST",
30
+ url ? ({ url } as never) : undefined,
31
+ undefined,
32
+ !directStatus && status !== undefined ? ({ status } as never) : undefined,
33
+ );
34
+ error.status = directStatus ? status : undefined;
35
+ return error;
36
+ }
37
+
38
+ function createBackend(overrides: Partial<Backend> = {}): Backend {
39
+ return {
40
+ id: "local-backend",
41
+ name: "Local Backend",
42
+ host: "http://localhost:3000",
43
+ apiKey: "test-key",
44
+ kind: "local",
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function activateBackend(backend: Backend) {
50
+ const selection = { backendId: backend.id, orgId: null };
51
+ window.localStorage.setItem("openhands-backends", JSON.stringify([backend]));
52
+ window.localStorage.setItem(
53
+ "openhands-active-backend",
54
+ JSON.stringify(selection),
55
+ );
56
+ window.sessionStorage.setItem(
57
+ "openhands-active-backend",
58
+ JSON.stringify(selection),
59
+ );
60
+ __resetActiveStoreForTests();
61
+ }
62
+
63
+ function executeFailingQuery(
64
+ client: QueryClient,
65
+ error: unknown,
66
+ {
67
+ meta,
68
+ queryKey = ["behavior", "failure"],
69
+ }: {
70
+ meta?: Record<string, unknown>;
71
+ queryKey?: readonly unknown[];
72
+ } = {},
73
+ ) {
74
+ return client.fetchQuery({
75
+ queryKey,
76
+ queryFn: async () => {
77
+ throw error;
78
+ },
79
+ meta,
80
+ retry: false,
81
+ });
82
+ }
83
+
84
+ function executeFailingMutation(
85
+ client: QueryClient,
86
+ error: unknown,
87
+ meta?: Record<string, unknown>,
88
+ ) {
89
+ const mutation = client.getMutationCache().build(client, {
90
+ mutationFn: async () => {
91
+ throw error;
92
+ },
93
+ meta,
94
+ retry: false,
95
+ });
96
+ return mutation.execute(undefined);
97
+ }
98
+
99
+ afterEach(() => {
100
+ vi.useRealTimers();
101
+ vi.unstubAllEnvs();
102
+ vi.restoreAllMocks();
103
+ window.localStorage.clear();
104
+ window.sessionStorage.clear();
105
+ delete (window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient })
106
+ .__OH_QUERY_CLIENT__;
107
+ __resetActiveStoreForTests();
108
+ __resetHealthStoreForTests();
109
+ });
110
+
111
+ describe("query client behavior", () => {
112
+ it("records successful queries for string backend identifiers only", async () => {
113
+ const client = createAgentServerQueryClient();
114
+ const malformedBackendId = 42;
115
+ const malformedMeta = { backendId: malformedBackendId } as unknown as {
116
+ backendId: string;
117
+ };
118
+ recordBackendFailure("backend-one", new Error("offline"));
119
+ recordBackendFailure(
120
+ malformedBackendId as unknown as string,
121
+ new Error("offline"),
122
+ );
123
+
124
+ await client.fetchQuery({
125
+ queryKey: ["health", "backend-one"],
126
+ queryFn: async () => "healthy",
127
+ meta: { backendId: "backend-one" },
128
+ });
129
+ await client.fetchQuery({
130
+ queryKey: ["health", "backend-two"],
131
+ queryFn: async () => "healthy",
132
+ meta: malformedMeta,
133
+ });
134
+ await client.fetchQuery({
135
+ queryKey: ["health", "unattributed"],
136
+ queryFn: async () => "healthy",
137
+ });
138
+
139
+ expect(getBackendHealthEntry("backend-one")).toBeNull();
140
+ expect(
141
+ getBackendHealthEntry(malformedBackendId as unknown as string),
142
+ ).not.toBeNull();
143
+ });
144
+
145
+ it.each([
146
+ { queryKey: ["settings"], description: "an unrelated query" },
147
+ { queryKey: ["user", "profile"], description: "another user query" },
148
+ {
149
+ queryKey: ["settings", "authenticated"],
150
+ description: "a non-user query ending in authenticated",
151
+ },
152
+ ])(
153
+ "invalidates authentication after a 401 from $description",
154
+ async ({ queryKey }) => {
155
+ const client = createAgentServerQueryClient();
156
+ const invalidateQueries = vi
157
+ .spyOn(client, "invalidateQueries")
158
+ .mockResolvedValue();
159
+ const error = createAxiosError({ status: 401 });
160
+
161
+ await expect(
162
+ executeFailingQuery(client, error, {
163
+ meta: { disableToast: true },
164
+ queryKey,
165
+ }),
166
+ ).rejects.toBe(error);
167
+
168
+ expect(invalidateQueries).toHaveBeenCalledWith({
169
+ queryKey: ["user", "authenticated"],
170
+ });
171
+ },
172
+ );
173
+
174
+ it("does not recursively invalidate authentication when that query fails", async () => {
175
+ const client = createAgentServerQueryClient();
176
+ const invalidateQueries = vi
177
+ .spyOn(client, "invalidateQueries")
178
+ .mockResolvedValue();
179
+ const error = createAxiosError({ status: 401 });
180
+
181
+ await expect(
182
+ executeFailingQuery(client, error, {
183
+ meta: { disableToast: true },
184
+ queryKey: ["user", "authenticated"],
185
+ }),
186
+ ).rejects.toBe(error);
187
+
188
+ expect(invalidateQueries).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it("recognizes a direct Axios status when a mutation receives a 401", async () => {
192
+ const client = createAgentServerQueryClient();
193
+ const invalidateQueries = vi
194
+ .spyOn(client, "invalidateQueries")
195
+ .mockResolvedValue();
196
+ const error = createAxiosError({ directStatus: true, status: 401 });
197
+
198
+ await expect(
199
+ executeFailingMutation(client, error, { disableToast: true }),
200
+ ).rejects.toBe(error);
201
+
202
+ expect(invalidateQueries).toHaveBeenCalledWith({
203
+ queryKey: ["user", "authenticated"],
204
+ });
205
+ });
206
+
207
+ it("does not invalidate authentication for non-401 failures", async () => {
208
+ const client = createAgentServerQueryClient();
209
+ const invalidateQueries = vi
210
+ .spyOn(client, "invalidateQueries")
211
+ .mockResolvedValue();
212
+ const error = createAxiosError({ status: 500 });
213
+
214
+ await expect(
215
+ executeFailingQuery(client, error, { meta: { disableToast: true } }),
216
+ ).rejects.toBe(error);
217
+
218
+ expect(invalidateQueries).not.toHaveBeenCalled();
219
+ });
220
+
221
+ it("preserves null mutation failures without invalidating authentication", async () => {
222
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
223
+ const client = createAgentServerQueryClient();
224
+ const invalidateQueries = vi
225
+ .spyOn(client, "invalidateQueries")
226
+ .mockResolvedValue();
227
+
228
+ await expect(executeFailingMutation(client, null)).rejects.toBeNull();
229
+
230
+ expect(invalidateQueries).not.toHaveBeenCalled();
231
+ expect(toast).toHaveBeenCalledWith(expect.any(String));
232
+ });
233
+
234
+ it.each([
235
+ {
236
+ directStatus: false,
237
+ url: undefined,
238
+ description: "has no request URL",
239
+ },
240
+ {
241
+ directStatus: false,
242
+ url: "https://cloud.example/api/conversations",
243
+ description: "targets the active cloud host",
244
+ },
245
+ {
246
+ directStatus: true,
247
+ url: "https://cloud.example/api/settings",
248
+ description: "reports 401 directly for the active cloud host",
249
+ },
250
+ ])(
251
+ "suppresses a cloud authentication toast when the request $description",
252
+ async ({ directStatus, url }) => {
253
+ activateBackend(
254
+ createBackend({
255
+ id: "cloud-backend",
256
+ name: "Cloud Backend",
257
+ host: "https://cloud.example///",
258
+ kind: "cloud",
259
+ }),
260
+ );
261
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
262
+ const client = createAgentServerQueryClient();
263
+ const error = createAxiosError({
264
+ directStatus,
265
+ message: "Cloud authentication failed",
266
+ status: 401,
267
+ url,
268
+ });
269
+
270
+ await expect(
271
+ executeFailingQuery(client, error, {
272
+ queryKey: ["cloud", url ?? "missing-url"],
273
+ }),
274
+ ).rejects.toBe(error);
275
+
276
+ expect(toast).not.toHaveBeenCalled();
277
+ },
278
+ );
279
+
280
+ it("shows a non-authentication error from the active cloud host", async () => {
281
+ activateBackend(
282
+ createBackend({
283
+ id: "cloud-backend",
284
+ host: "https://cloud.example",
285
+ kind: "cloud",
286
+ }),
287
+ );
288
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
289
+ const client = createAgentServerQueryClient();
290
+ const error = createAxiosError({
291
+ message: "Cloud service unavailable",
292
+ status: 503,
293
+ url: "https://cloud.example/api/settings",
294
+ });
295
+
296
+ await expect(
297
+ executeFailingQuery(client, error, {
298
+ queryKey: ["cloud", "service-unavailable"],
299
+ }),
300
+ ).rejects.toBe(error);
301
+
302
+ expect(toast).toHaveBeenCalledWith("Cloud service unavailable");
303
+ });
304
+
305
+ it("shows a 401 toast when a cloud request targets another host", async () => {
306
+ activateBackend(
307
+ createBackend({
308
+ id: "cloud-backend",
309
+ host: "https://cloud.example",
310
+ kind: "cloud",
311
+ }),
312
+ );
313
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
314
+ const client = createAgentServerQueryClient();
315
+ const error = createAxiosError({
316
+ message: "Foreign host authentication failed",
317
+ status: 401,
318
+ url: "https://different.example/api",
319
+ });
320
+
321
+ await expect(
322
+ executeFailingQuery(client, error, {
323
+ queryKey: ["cloud", "foreign-host"],
324
+ }),
325
+ ).rejects.toBe(error);
326
+
327
+ expect(toast).toHaveBeenCalledWith("Foreign host authentication failed");
328
+ });
329
+
330
+ it("shows a 401 toast for an active local backend", async () => {
331
+ activateBackend(createBackend());
332
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
333
+ const client = createAgentServerQueryClient();
334
+ const error = createAxiosError({
335
+ message: "Local authentication failed",
336
+ status: 401,
337
+ });
338
+
339
+ await expect(
340
+ executeFailingQuery(client, error, {
341
+ queryKey: ["local", "authentication"],
342
+ }),
343
+ ).rejects.toBe(error);
344
+
345
+ expect(toast).toHaveBeenCalledWith("Local authentication failed");
346
+ });
347
+
348
+ it("deduplicates query toasts until the cooldown expires", async () => {
349
+ vi.useFakeTimers();
350
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
351
+ const client = createAgentServerQueryClient();
352
+ const first = new AxiosError("Repeated query failure");
353
+ const second = new AxiosError("Repeated query failure");
354
+
355
+ await expect(
356
+ executeFailingQuery(client, first, {
357
+ queryKey: ["dedupe", "first"],
358
+ }),
359
+ ).rejects.toBe(first);
360
+ await expect(
361
+ executeFailingQuery(client, second, {
362
+ queryKey: ["dedupe", "second"],
363
+ }),
364
+ ).rejects.toBe(second);
365
+ expect(toast).toHaveBeenCalledTimes(1);
366
+
367
+ await vi.advanceTimersByTimeAsync(3000);
368
+ const afterCooldown = new AxiosError("Repeated query failure");
369
+ await expect(
370
+ executeFailingQuery(client, afterCooldown, {
371
+ queryKey: ["dedupe", "after-cooldown"],
372
+ }),
373
+ ).rejects.toBe(afterCooldown);
374
+
375
+ expect(toast).toHaveBeenCalledTimes(2);
376
+ });
377
+
378
+ it("uses the translated generic query error when no message is available", async () => {
379
+ vi.useFakeTimers();
380
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
381
+ const client = createAgentServerQueryClient();
382
+ const first = {};
383
+ const duplicate = {};
384
+
385
+ await expect(
386
+ executeFailingQuery(client, first, {
387
+ queryKey: ["generic", "first"],
388
+ }),
389
+ ).rejects.toBe(first);
390
+ await expect(
391
+ executeFailingQuery(client, duplicate, {
392
+ queryKey: ["generic", "duplicate"],
393
+ }),
394
+ ).rejects.toBe(duplicate);
395
+
396
+ expect(toast).toHaveBeenCalledWith(expect.any(String));
397
+ expect(toast).toHaveBeenCalledTimes(1);
398
+
399
+ await vi.advanceTimersByTimeAsync(3000);
400
+ const afterCooldown = {};
401
+ await expect(
402
+ executeFailingQuery(client, afterCooldown, {
403
+ queryKey: ["generic", "after-cooldown"],
404
+ }),
405
+ ).rejects.toBe(afterCooldown);
406
+
407
+ expect(toast).toHaveBeenCalledTimes(2);
408
+ });
409
+
410
+ it("honors mutation toast metadata", async () => {
411
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
412
+ const client = createAgentServerQueryClient();
413
+ const visible = new AxiosError("Visible mutation failure");
414
+ const suppressed = new AxiosError("Suppressed mutation failure");
415
+
416
+ await expect(executeFailingMutation(client, visible)).rejects.toBe(visible);
417
+ await expect(
418
+ executeFailingMutation(client, suppressed, { disableToast: true }),
419
+ ).rejects.toBe(suppressed);
420
+
421
+ expect(toast).toHaveBeenCalledOnce();
422
+ expect(toast).toHaveBeenCalledWith("Visible mutation failure");
423
+ });
424
+
425
+ it("suppresses matching cloud-auth mutation errors", async () => {
426
+ activateBackend(
427
+ createBackend({
428
+ id: "cloud-backend",
429
+ host: "https://cloud.example",
430
+ kind: "cloud",
431
+ }),
432
+ );
433
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
434
+ const client = createAgentServerQueryClient();
435
+ const error = createAxiosError({
436
+ message: "Cloud mutation authentication failed",
437
+ status: 401,
438
+ url: "https://cloud.example/api/settings",
439
+ });
440
+
441
+ await expect(executeFailingMutation(client, error)).rejects.toBe(error);
442
+
443
+ expect(toast).not.toHaveBeenCalled();
444
+ });
445
+
446
+ it("uses the translated generic mutation error when no message is available", async () => {
447
+ const toast = vi.spyOn(ToastHandlers, "displayErrorToast");
448
+ const client = createAgentServerQueryClient();
449
+ const error = {};
450
+
451
+ await expect(executeFailingMutation(client, error)).rejects.toBe(error);
452
+
453
+ expect(toast).toHaveBeenCalledWith(expect.any(String));
454
+ });
455
+ });
456
+
457
+ describe("query client selection and proxy behavior", () => {
458
+ it("creates one default client and exposes it in development", async () => {
459
+ vi.resetModules();
460
+ const config = await import("#/query-client-config");
461
+
462
+ const first = config.getDefaultQueryClient();
463
+ const second = config.getDefaultQueryClient();
464
+
465
+ expect(first).toBeInstanceOf(QueryClient);
466
+ expect(second).toBe(first);
467
+ expect(config.getQueryClient()).toBe(first);
468
+ expect(
469
+ (window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient })
470
+ .__OH_QUERY_CLIENT__,
471
+ ).toBe(first);
472
+ });
473
+
474
+ it("selects custom clients and forwards proxy reads, calls, and writes", async () => {
475
+ vi.resetModules();
476
+ const config = await import("#/query-client-config");
477
+ const custom = new QueryClient();
478
+
479
+ expect(config.setQueryClient(custom)).toBe(custom);
480
+ expect(config.getQueryClient()).toBe(custom);
481
+
482
+ config.queryClient.setQueryData(["proxy", "value"], "forwarded");
483
+ expect(custom.getQueryData(["proxy", "value"])).toBe("forwarded");
484
+
485
+ const extendedProxy = config.queryClient as QueryClient & {
486
+ marker?: string;
487
+ };
488
+ const extendedClient = custom as QueryClient & { marker?: string };
489
+ extendedProxy.marker = "proxy-write";
490
+ expect(extendedProxy.marker).toBe("proxy-write");
491
+ expect(extendedClient.marker).toBe("proxy-write");
492
+
493
+ expect(config.setQueryClient(undefined)).toBe(
494
+ config.getDefaultQueryClient(),
495
+ );
496
+ expect(config.setQueryClient(null)).toBe(config.getDefaultQueryClient());
497
+ });
498
+
499
+ it.each([
500
+ { mockApi: "true", expectedExposure: true },
501
+ { mockApi: "false", expectedExposure: false },
502
+ ])(
503
+ "sets window exposure to $expectedExposure outside development when VITE_MOCK_API is $mockApi",
504
+ async ({ expectedExposure, mockApi }) => {
505
+ vi.stubEnv("DEV", false);
506
+ vi.stubEnv("VITE_MOCK_API", mockApi);
507
+ vi.resetModules();
508
+ const config = await import("#/query-client-config");
509
+
510
+ const client = config.getDefaultQueryClient();
511
+ const exposed = (
512
+ window as typeof window & { __OH_QUERY_CLIENT__?: QueryClient }
513
+ ).__OH_QUERY_CLIENT__;
514
+
515
+ if (expectedExposure) {
516
+ expect(exposed).toBe(client);
517
+ } else {
518
+ expect(exposed).toBeUndefined();
519
+ }
520
+ },
521
+ );
522
+ });
__tests__/query-client-config.test.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AxiosError } from "axios";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createAgentServerQueryClient } from "#/query-client-config";
4
+ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
5
+ import * as ToastHandlers from "#/utils/custom-toast-handlers";
6
+
7
+ afterEach(() => {
8
+ window.localStorage.clear();
9
+ window.sessionStorage.clear();
10
+ __resetActiveStoreForTests();
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ describe("createAgentServerQueryClient", () => {
15
+ it("does not show a toast when query meta disables toasts", async () => {
16
+ const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
17
+ const client = createAgentServerQueryClient();
18
+
19
+ await expect(
20
+ client.fetchQuery({
21
+ queryKey: ["config", "suppressed"],
22
+ queryFn: async () => {
23
+ throw new AxiosError("suppressed query error");
24
+ },
25
+ meta: { disableToast: true },
26
+ retry: false,
27
+ }),
28
+ ).rejects.toThrow("suppressed query error");
29
+
30
+ expect(toastSpy).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("shows a toast when query meta does not disable toasts", async () => {
34
+ const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
35
+ const client = createAgentServerQueryClient();
36
+
37
+ await expect(
38
+ client.fetchQuery({
39
+ queryKey: ["config", "toast"],
40
+ queryFn: async () => {
41
+ throw new AxiosError("query error with toast");
42
+ },
43
+ retry: false,
44
+ }),
45
+ ).rejects.toThrow("query error with toast");
46
+
47
+ expect(toastSpy).toHaveBeenCalledWith("query error with toast");
48
+ });
49
+
50
+ it("does not show raw 401 toasts while the active cloud backend is logged out", async () => {
51
+ const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
52
+ const backend = {
53
+ id: "cloud-expired",
54
+ name: "OpenHands Cloud",
55
+ host: "https://app.all-hands.dev",
56
+ apiKey: "expired-token",
57
+ kind: "cloud",
58
+ };
59
+ window.localStorage.setItem(
60
+ "openhands-backends",
61
+ JSON.stringify([backend]),
62
+ );
63
+ window.localStorage.setItem(
64
+ "openhands-active-backend",
65
+ JSON.stringify({ backendId: backend.id, orgId: null }),
66
+ );
67
+ window.sessionStorage.setItem(
68
+ "openhands-active-backend",
69
+ JSON.stringify({ backendId: backend.id, orgId: null }),
70
+ );
71
+ __resetActiveStoreForTests();
72
+ const client = createAgentServerQueryClient();
73
+
74
+ await expect(
75
+ client.fetchQuery({
76
+ queryKey: ["cloud", "logged-out"],
77
+ queryFn: async () => {
78
+ throw new AxiosError(
79
+ "Request failed with status code 401",
80
+ "ERR_BAD_REQUEST",
81
+ undefined,
82
+ undefined,
83
+ { status: 401 } as never,
84
+ );
85
+ },
86
+ retry: false,
87
+ }),
88
+ ).rejects.toThrow("Request failed with status code 401");
89
+
90
+ expect(toastSpy).not.toHaveBeenCalled();
91
+ });
92
+ });
__tests__/root.test.tsx ADDED
@@ -0,0 +1,926 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { createRoutesStub } from "react-router";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { http, HttpResponse } from "msw";
6
+ import App, { links } from "#/root";
7
+ import { server } from "#/mocks/node";
8
+ import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
9
+ import { LOCKED_CLOUD_BACKEND_ID } from "#/api/backend-registry/default-backend";
10
+ import { __resetHealthStoreForTests } from "#/api/backend-registry/health-store";
11
+ import {
12
+ BACKEND_HEALTH_STORAGE_KEY,
13
+ MAX_CONSECUTIVE_FAILURES,
14
+ } from "#/api/backend-registry/health-storage";
15
+ import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health";
16
+ import { ActiveBackendProvider } from "#/contexts/active-backend-context";
17
+ import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion";
18
+
19
+ const TRANSLATIONS: Record<string, string> = {
20
+ BACKEND$MANAGE_TITLE: "Manage backends",
21
+ BACKEND$RECONNECT_CLOUD_TITLE: "Reconnect to Cloud",
22
+ BACKEND$RECONNECT_CLOUD: "Reconnect to Cloud",
23
+ BACKEND$MANAGE_EMPTY: "No backends yet.",
24
+ BACKEND$ADD: "+ Add Backend",
25
+ BACKEND$LOG_BACK_IN: "Log back in",
26
+ BACKEND$LOGGED_OUT: "Logged out",
27
+ BACKEND$KIND_LOCAL: "Local",
28
+ BACKEND$KIND_CLOUD: "Cloud",
29
+ BACKEND$EDIT: "Edit",
30
+ BACKEND$REMOVE: "Remove",
31
+ HOME$DONE: "Done",
32
+ };
33
+
34
+ vi.mock("react-i18next", () => ({
35
+ useTranslation: () => ({
36
+ t: (key: string, options?: Record<string, string | number>) => {
37
+ let value = TRANSLATIONS[key] ?? key;
38
+ for (const [optionKey, optionValue] of Object.entries(options ?? {})) {
39
+ value = value.replaceAll(`{{${optionKey}}}`, String(optionValue));
40
+ }
41
+ return value;
42
+ },
43
+ }),
44
+ }));
45
+
46
+ vi.mock("#/components/features/onboarding/onboarding-modal", async () => {
47
+ const React = await import("react");
48
+ const { useNavigation } = await import("#/context/navigation-context");
49
+
50
+ return {
51
+ OnboardingModal: ({ onClose }: { onClose: () => void }) => {
52
+ const { navigate } = useNavigation();
53
+ return React.createElement(
54
+ "div",
55
+ { "data-testid": "onboarding-modal" },
56
+ React.createElement("div", {
57
+ "data-testid": "onboarding-step-check-backend",
58
+ }),
59
+ React.createElement(
60
+ "button",
61
+ {
62
+ type: "button",
63
+ "data-testid": "mock-onboarding-launch",
64
+ onClick: () => {
65
+ navigate("/conversations/mock-conversation");
66
+ onClose();
67
+ },
68
+ },
69
+ "Launch conversation",
70
+ ),
71
+ );
72
+ },
73
+ };
74
+ });
75
+
76
+ const ORIGINAL_LOCATION = window.location;
77
+
78
+ const RouterStub = createRoutesStub([
79
+ {
80
+ Component: App,
81
+ path: "/",
82
+ children: [
83
+ {
84
+ Component: () => <div data-testid="app-outlet">app outlet</div>,
85
+ path: "/",
86
+ },
87
+ {
88
+ Component: () => (
89
+ <div data-testid="conversation-outlet">conversation outlet</div>
90
+ ),
91
+ path: "/conversations/:conversationId",
92
+ },
93
+ ],
94
+ },
95
+ ]);
96
+
97
+ const renderApp = (initialEntries: string[] = ["/"]) =>
98
+ render(<RouterStub initialEntries={initialEntries} />, {
99
+ wrapper: ({ children }) => (
100
+ <QueryClientProvider
101
+ client={
102
+ new QueryClient({
103
+ defaultOptions: { queries: { retry: false } },
104
+ })
105
+ }
106
+ >
107
+ <ActiveBackendProvider>{children}</ActiveBackendProvider>
108
+ </QueryClientProvider>
109
+ ),
110
+ });
111
+
112
+ const COOKIE_DEPLOYMENT_ORIGIN = "https://pr-254.staging.openhands.dev";
113
+
114
+ /**
115
+ * Simulate an OHE-hosted Canvas: served from the locked Cloud host itself, so
116
+ * the single locked backend authenticates with the main-app session cookie.
117
+ * Returns the `window.location.assign` spy that observes login redirects.
118
+ */
119
+ function mockLockedCookieDeployment() {
120
+ const assign = vi.fn();
121
+ Object.defineProperty(window, "location", {
122
+ configurable: true,
123
+ value: {
124
+ ...ORIGINAL_LOCATION,
125
+ origin: COOKIE_DEPLOYMENT_ORIGIN,
126
+ hostname: "pr-254.staging.openhands.dev",
127
+ pathname: "/canvas",
128
+ search: "",
129
+ hash: "",
130
+ assign,
131
+ },
132
+ });
133
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", COOKIE_DEPLOYMENT_ORIGIN);
134
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
135
+ delete (window as unknown as Record<string, unknown>)
136
+ .__AGENT_CANVAS_SESSION_API_KEY__;
137
+ __resetActiveStoreForTests();
138
+ return assign;
139
+ }
140
+
141
+ describe("App root agent-server availability guard", () => {
142
+ beforeEach(() => {
143
+ window.localStorage.clear();
144
+ __resetHealthStoreForTests();
145
+ vi.unstubAllEnvs();
146
+ delete (window as unknown as Record<string, unknown>)
147
+ .__AGENT_CANVAS_AUTH_REQUIRED__;
148
+ delete (window as unknown as Record<string, unknown>)
149
+ .__AGENT_CANVAS_LOCK_TO_CLOUD__;
150
+ (
151
+ window as unknown as Record<string, unknown>
152
+ ).__AGENT_CANVAS_SESSION_API_KEY__ = "test-session-key";
153
+ __resetActiveStoreForTests();
154
+ });
155
+
156
+ afterEach(() => {
157
+ Object.defineProperty(window, "location", {
158
+ configurable: true,
159
+ value: ORIGINAL_LOCATION,
160
+ });
161
+ });
162
+
163
+ it("shows first-run onboarding before the auth gate when public mode has no backend key", async () => {
164
+ vi.stubEnv("VITE_AUTH_REQUIRED", "true");
165
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
166
+ delete (window as unknown as Record<string, unknown>)
167
+ .__AGENT_CANVAS_SESSION_API_KEY__;
168
+ window.localStorage.clear();
169
+ __resetActiveStoreForTests();
170
+
171
+ renderApp(["/"]);
172
+
173
+ await waitFor(() => {
174
+ expect(
175
+ screen.getByTestId("first-run-onboarding-screen"),
176
+ ).toBeInTheDocument();
177
+ });
178
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
179
+ expect(
180
+ await screen.findByTestId("onboarding-step-check-backend"),
181
+ ).toBeInTheDocument();
182
+ expect(
183
+ screen.queryByTestId("api-key-entry-screen"),
184
+ ).not.toBeInTheDocument();
185
+ });
186
+
187
+ it("shows first-run onboarding before the recovery modal when no backend is configured", async () => {
188
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
189
+ delete (window as unknown as Record<string, unknown>)
190
+ .__AGENT_CANVAS_SESSION_API_KEY__;
191
+ window.localStorage.clear();
192
+ __resetActiveStoreForTests();
193
+
194
+ renderApp(["/"]);
195
+
196
+ await waitFor(() => {
197
+ expect(
198
+ screen.getByTestId("first-run-onboarding-screen"),
199
+ ).toBeInTheDocument();
200
+ });
201
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
202
+ expect(
203
+ screen.queryByTestId("agent-server-onboarding-screen"),
204
+ ).not.toBeInTheDocument();
205
+ expect(
206
+ screen.queryByTestId("manage-backends-modal"),
207
+ ).not.toBeInTheDocument();
208
+ });
209
+
210
+ it("lets root-level onboarding navigate to the launched conversation before closing", async () => {
211
+ server.use(
212
+ http.get("*/server_info", () =>
213
+ HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }),
214
+ ),
215
+ );
216
+
217
+ renderApp(["/"]);
218
+
219
+ fireEvent.click(await screen.findByTestId("mock-onboarding-launch"));
220
+
221
+ await waitFor(() => {
222
+ expect(screen.getByTestId("conversation-outlet")).toBeInTheDocument();
223
+ });
224
+ expect(window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY)).toBe(
225
+ "1",
226
+ );
227
+ expect(
228
+ screen.queryByTestId("first-run-onboarding-screen"),
229
+ ).not.toBeInTheDocument();
230
+ });
231
+
232
+ it("shows first-run onboarding before the recovery modal when locked to Cloud with no backend", async () => {
233
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
234
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
235
+ delete (window as unknown as Record<string, unknown>)
236
+ .__AGENT_CANVAS_SESSION_API_KEY__;
237
+ window.localStorage.clear();
238
+ __resetActiveStoreForTests();
239
+
240
+ renderApp(["/"]);
241
+
242
+ await waitFor(() => {
243
+ expect(
244
+ screen.getByTestId("first-run-onboarding-screen"),
245
+ ).toBeInTheDocument();
246
+ });
247
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
248
+ expect(
249
+ screen.queryByTestId("agent-server-onboarding-screen"),
250
+ ).not.toBeInTheDocument();
251
+ expect(
252
+ screen.queryByTestId("manage-backends-modal"),
253
+ ).not.toBeInTheDocument();
254
+ });
255
+
256
+ it("shows first-run onboarding when locked to Cloud even if a session API key is baked in", async () => {
257
+ // Reproduces Hiep's report on PR #1389: a pre-built bundle with a baked-in
258
+ // VITE_SESSION_API_KEY plus --lock-to-cloud used to seed a disconnected
259
+ // Local backend, which skipped onboarding and landed on the Manage Backends
260
+ // recovery modal. Locked mode must not seed a Local backend, so onboarding
261
+ // still owns the first-run Cloud login.
262
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
263
+ vi.stubEnv("VITE_SESSION_API_KEY", "baked-session-key");
264
+ (
265
+ window as unknown as Record<string, unknown>
266
+ ).__AGENT_CANVAS_SESSION_API_KEY__ = "baked-session-key";
267
+ window.localStorage.clear();
268
+ __resetActiveStoreForTests();
269
+
270
+ renderApp(["/"]);
271
+
272
+ await waitFor(() => {
273
+ expect(
274
+ screen.getByTestId("first-run-onboarding-screen"),
275
+ ).toBeInTheDocument();
276
+ });
277
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
278
+ expect(
279
+ screen.queryByTestId("agent-server-onboarding-screen"),
280
+ ).not.toBeInTheDocument();
281
+ expect(
282
+ screen.queryByTestId("manage-backends-modal"),
283
+ ).not.toBeInTheDocument();
284
+ // No Local backend should have been seeded into the registry.
285
+ expect(window.localStorage.getItem("openhands-backends")).toBeNull();
286
+ });
287
+
288
+ it("shows first-run onboarding when locked to Cloud with a stale persisted Local backend", async () => {
289
+ // A Local backend persisted from a previous non-locked session must not
290
+ // bypass onboarding once the deployment is locked to Cloud.
291
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
292
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
293
+ delete (window as unknown as Record<string, unknown>)
294
+ .__AGENT_CANVAS_SESSION_API_KEY__;
295
+ window.localStorage.setItem(
296
+ "openhands-backends",
297
+ JSON.stringify([
298
+ {
299
+ id: "default-local",
300
+ name: "Local",
301
+ host: "http://127.0.0.1:8000",
302
+ apiKey: "stale-key",
303
+ kind: "local",
304
+ },
305
+ ]),
306
+ );
307
+ window.localStorage.setItem(
308
+ "openhands-active-backend",
309
+ JSON.stringify({ backendId: "default-local", orgId: null }),
310
+ );
311
+ __resetActiveStoreForTests();
312
+
313
+ renderApp(["/"]);
314
+
315
+ await waitFor(() => {
316
+ expect(
317
+ screen.getByTestId("first-run-onboarding-screen"),
318
+ ).toBeInTheDocument();
319
+ });
320
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
321
+ expect(
322
+ screen.queryByTestId("manage-backends-modal"),
323
+ ).not.toBeInTheDocument();
324
+ });
325
+
326
+ it("forces first-run onboarding in locked mode even when a stale Local backend reports a configured LLM", async () => {
327
+ // Critical regression for PR #1389 review: in locked-to-Cloud mode the
328
+ // stale Local backend must not bypass onboarding, even when it happens
329
+ // to report a configured LLM. The user must be routed through the Cloud
330
+ // login / replacement flow instead.
331
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
332
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
333
+ delete (window as unknown as Record<string, unknown>)
334
+ .__AGENT_CANVAS_SESSION_API_KEY__;
335
+ window.localStorage.setItem(
336
+ "openhands-backends",
337
+ JSON.stringify([
338
+ {
339
+ id: "user-added-local",
340
+ name: "My agent-server",
341
+ host: "http://127.0.0.1:8000",
342
+ apiKey: "stale-key",
343
+ kind: "local",
344
+ },
345
+ ]),
346
+ );
347
+ window.localStorage.setItem(
348
+ "openhands-active-backend",
349
+ JSON.stringify({ backendId: "user-added-local", orgId: null }),
350
+ );
351
+ __resetActiveStoreForTests();
352
+ server.use(
353
+ http.get("*/api/settings", () =>
354
+ HttpResponse.json({
355
+ llm_api_key_is_set: true,
356
+ agent_settings: {
357
+ llm: { model: "openai/gpt-5.5", api_key: "stored" },
358
+ },
359
+ }),
360
+ ),
361
+ http.get("*/server_info", () =>
362
+ HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }),
363
+ ),
364
+ );
365
+
366
+ renderApp(["/"]);
367
+
368
+ await waitFor(() => {
369
+ expect(
370
+ screen.getByTestId("first-run-onboarding-screen"),
371
+ ).toBeInTheDocument();
372
+ });
373
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
374
+ expect(
375
+ screen.queryByTestId("manage-backends-modal"),
376
+ ).not.toBeInTheDocument();
377
+ // Backend readiness must NOT persist onboarding completion.
378
+ expect(
379
+ window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
380
+ ).toBeNull();
381
+ });
382
+
383
+ it("forces first-run onboarding in locked mode when a Cloud backend points at a different host with a configured LLM", async () => {
384
+ // Companion to the stale-Local test: a Cloud backend on a *different*
385
+ // host than the locked Cloud host must also be forced through
386
+ // onboarding, even if it reports a configured LLM. `kind === "cloud"`
387
+ // alone is not enough β€” the host must match the locked host.
388
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
389
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
390
+ delete (window as unknown as Record<string, unknown>)
391
+ .__AGENT_CANVAS_SESSION_API_KEY__;
392
+ const otherCloud = {
393
+ id: "other-cloud",
394
+ name: "Other Cloud",
395
+ host: "https://other-cloud.example.com",
396
+ apiKey: "other-token",
397
+ kind: "cloud",
398
+ };
399
+ window.localStorage.setItem(
400
+ "openhands-backends",
401
+ JSON.stringify([otherCloud]),
402
+ );
403
+ window.localStorage.setItem(
404
+ "openhands-active-backend",
405
+ JSON.stringify({ backendId: otherCloud.id, orgId: null }),
406
+ );
407
+ __resetActiveStoreForTests();
408
+ server.use(
409
+ http.get("*/api/settings", () =>
410
+ HttpResponse.json({
411
+ llm_api_key_set: true,
412
+ agent_settings: {
413
+ llm: { model: "openai/gpt-5.5" },
414
+ },
415
+ }),
416
+ ),
417
+ http.get("*/server_info", () =>
418
+ HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }),
419
+ ),
420
+ );
421
+
422
+ renderApp(["/"]);
423
+
424
+ await waitFor(() => {
425
+ expect(
426
+ screen.getByTestId("first-run-onboarding-screen"),
427
+ ).toBeInTheDocument();
428
+ });
429
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
430
+ expect(
431
+ window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
432
+ ).toBeNull();
433
+ });
434
+
435
+ it("shows first-run onboarding when locked to Cloud even if onboarding was previously completed", async () => {
436
+ // Reproduces hieptl's report on PR #1389: the user had previously
437
+ // completed onboarding in a non-locked session (so the
438
+ // `openhands-onboarded` localStorage flag is set), then relaunched the
439
+ // static server with --lock-to-cloud. The stale completion flag used to
440
+ // suppress first-run onboarding, so the app fell through to the Manage
441
+ // Backends recovery modal ("Add Backend") instead of going straight to
442
+ // Cloud login. In locked-to-Cloud mode the completion flag must not
443
+ // bypass onboarding when the active backend is not a connected Cloud
444
+ // backend.
445
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
446
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
447
+ delete (window as unknown as Record<string, unknown>)
448
+ .__AGENT_CANVAS_SESSION_API_KEY__;
449
+ window.localStorage.clear();
450
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
451
+ __resetActiveStoreForTests();
452
+
453
+ renderApp(["/"]);
454
+
455
+ await waitFor(() => {
456
+ expect(
457
+ screen.getByTestId("first-run-onboarding-screen"),
458
+ ).toBeInTheDocument();
459
+ });
460
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
461
+ expect(
462
+ screen.queryByTestId("agent-server-onboarding-screen"),
463
+ ).not.toBeInTheDocument();
464
+ expect(
465
+ screen.queryByTestId("manage-backends-modal"),
466
+ ).not.toBeInTheDocument();
467
+ });
468
+
469
+ it("shows the auth gate after onboarding was already completed", async () => {
470
+ vi.stubEnv("VITE_AUTH_REQUIRED", "true");
471
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
472
+ delete (window as unknown as Record<string, unknown>)
473
+ .__AGENT_CANVAS_SESSION_API_KEY__;
474
+ window.localStorage.clear();
475
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
476
+ __resetActiveStoreForTests();
477
+
478
+ renderApp(["/"]);
479
+
480
+ await waitFor(() => {
481
+ expect(screen.getByTestId("api-key-entry-screen")).toBeInTheDocument();
482
+ });
483
+ expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument();
484
+ });
485
+
486
+ it("shows the manage-backends modal when the connected server reports an old version", async () => {
487
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
488
+ server.use(
489
+ http.get("*/server_info", () =>
490
+ HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.27.1" }),
491
+ ),
492
+ );
493
+
494
+ renderApp(["/"]);
495
+
496
+ await waitFor(() => {
497
+ expect(
498
+ screen.getByTestId("agent-server-onboarding-screen"),
499
+ ).toBeInTheDocument();
500
+ });
501
+
502
+ await waitFor(() => {
503
+ expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
504
+ });
505
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
506
+ });
507
+
508
+ it("shows the manage-backends modal when the server omits a version field", async () => {
509
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
510
+ server.use(
511
+ http.get("*/server_info", () =>
512
+ HttpResponse.json({ uptime: 0, idle_time: 0 }),
513
+ ),
514
+ );
515
+
516
+ renderApp(["/"]);
517
+
518
+ await waitFor(() => {
519
+ expect(
520
+ screen.getByTestId("agent-server-onboarding-screen"),
521
+ ).toBeInTheDocument();
522
+ });
523
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
524
+ });
525
+
526
+ it("shows the manage-backends modal when the backend is unreachable", async () => {
527
+ let serverInfoRequests = 0;
528
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
529
+
530
+ // Use "*" prefix to match both relative paths and absolute URLs (e.g.,
531
+ // http://127.0.0.1:8000/server_info) when VITE_BACKEND_BASE_URL is configured.
532
+ server.use(
533
+ http.get("*/server_info", () => {
534
+ serverInfoRequests += 1;
535
+ return HttpResponse.error();
536
+ }),
537
+ );
538
+
539
+ renderApp(["/"]);
540
+
541
+ await waitFor(() => {
542
+ expect(
543
+ screen.getByTestId("agent-server-onboarding-screen"),
544
+ ).toBeInTheDocument();
545
+ });
546
+
547
+ // The onboarding placeholder now hosts the Manage Backends modal
548
+ // directly so the user can edit/add a backend immediately. The
549
+ // modal additionally probes /server_info per registered backend
550
+ // for its status dot + version label, so the request count is
551
+ // bounded but greater than the single config probe.
552
+ await waitFor(() => {
553
+ expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
554
+ });
555
+ expect(serverInfoRequests).toBeGreaterThanOrEqual(1);
556
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
557
+ });
558
+
559
+ it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => {
560
+ const cloudBackend = {
561
+ id: "cloud-expired",
562
+ name: "OpenHands Cloud",
563
+ host: "https://app.all-hands.dev",
564
+ apiKey: "expired-token",
565
+ kind: "cloud",
566
+ };
567
+ window.localStorage.setItem(
568
+ "openhands-backends",
569
+ JSON.stringify([cloudBackend]),
570
+ );
571
+ window.localStorage.setItem(
572
+ "openhands-active-backend",
573
+ JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
574
+ );
575
+ window.sessionStorage.setItem(
576
+ "openhands-active-backend",
577
+ JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
578
+ );
579
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
580
+ __resetActiveStoreForTests();
581
+ server.use(
582
+ http.get("https://app.all-hands.dev/api/keys/current", () =>
583
+ HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }),
584
+ ),
585
+ );
586
+
587
+ renderApp(["/"]);
588
+
589
+ await waitFor(() => {
590
+ expect(
591
+ screen.getByTestId("agent-server-onboarding-screen"),
592
+ ).toBeInTheDocument();
593
+ });
594
+ expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument();
595
+ expect(screen.getByText("Logged out")).toBeInTheDocument();
596
+ expect(
597
+ screen.getByRole("button", { name: "Log back in" }),
598
+ ).toBeInTheDocument();
599
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
600
+ });
601
+
602
+ it("shows locked Cloud reconnect recovery without add-backend controls", async () => {
603
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
604
+ const cloudBackend = {
605
+ id: "cloud-expired",
606
+ name: "OpenHands Cloud",
607
+ host: "https://app.all-hands.dev",
608
+ apiKey: "expired-token",
609
+ kind: "cloud",
610
+ };
611
+ window.localStorage.setItem(
612
+ "openhands-backends",
613
+ JSON.stringify([cloudBackend]),
614
+ );
615
+ window.localStorage.setItem(
616
+ "openhands-active-backend",
617
+ JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
618
+ );
619
+ window.sessionStorage.setItem(
620
+ "openhands-active-backend",
621
+ JSON.stringify({ backendId: cloudBackend.id, orgId: null }),
622
+ );
623
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
624
+ __resetActiveStoreForTests();
625
+ server.use(
626
+ http.get("https://app.all-hands.dev/api/keys/current", () =>
627
+ HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }),
628
+ ),
629
+ );
630
+
631
+ renderApp(["/"]);
632
+
633
+ await waitFor(() => {
634
+ expect(
635
+ screen.getByRole("heading", { name: "Reconnect to Cloud" }),
636
+ ).toBeInTheDocument();
637
+ });
638
+ expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument();
639
+ expect(
640
+ screen.getByTestId("manage-backends-reconnect-cloud-login-button"),
641
+ ).toHaveTextContent("Reconnect to Cloud");
642
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
643
+ });
644
+
645
+ it("renders the routed page when the agent server is reachable", async () => {
646
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
647
+
648
+ renderApp(["/"]);
649
+
650
+ await waitFor(() => {
651
+ expect(screen.getByTestId("app-outlet")).toBeInTheDocument();
652
+ });
653
+
654
+ expect(
655
+ screen.queryByTestId("agent-server-onboarding-screen"),
656
+ ).not.toBeInTheDocument();
657
+ });
658
+
659
+ it("shows first-run onboarding for the launcher-seeded default-local backend even when the agent-server reports a configured LLM", async () => {
660
+ // Regression for mock-llm-onboarding-regressions.spec.ts:16
661
+ // ("keeps the modal open on backdrop click and Escape") and
662
+ // mock-llm-auth-modes.spec.ts:57 ("reaches the onboarding modal
663
+ // without pre-seeded localStorage"). The shared mock-LLM
664
+ // agent-server retains a previously-configured LLM across browser
665
+ // sessions, so a genuinely fresh browser install (launcher-seeded
666
+ // default-local backend, no `openhands-onboarded` flag) must NOT
667
+ // have onboarding auto-marked complete by backend readiness.
668
+ vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000");
669
+ vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key");
670
+ // The launcher-seeded default-local backend (id
671
+ // SEEDED_DEFAULT_BACKEND_ID) is created from these env stubs by
672
+ // readStoredBackends().
673
+ __resetActiveStoreForTests();
674
+ server.use(
675
+ http.get("*/api/settings", () =>
676
+ HttpResponse.json({
677
+ llm_api_key_is_set: true,
678
+ agent_settings: {
679
+ llm: { model: "openai/gpt-5.5", api_key: "stored" },
680
+ },
681
+ }),
682
+ ),
683
+ http.get("*/server_info", () =>
684
+ HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }),
685
+ ),
686
+ );
687
+
688
+ renderApp(["/"]);
689
+
690
+ await waitFor(() => {
691
+ expect(
692
+ screen.getByTestId("first-run-onboarding-screen"),
693
+ ).toBeInTheDocument();
694
+ });
695
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
696
+
697
+ expect(
698
+ window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY),
699
+ ).toBeNull();
700
+ });
701
+
702
+ it("renders Cloud login directly for a fresh locked-to-Cloud first run", async () => {
703
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
704
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
705
+ delete (window as unknown as Record<string, unknown>)
706
+ .__AGENT_CANVAS_SESSION_API_KEY__;
707
+ __resetActiveStoreForTests();
708
+
709
+ renderApp(["/"]);
710
+
711
+ await waitFor(() => {
712
+ expect(
713
+ screen.getByTestId("first-run-onboarding-screen"),
714
+ ).toBeInTheDocument();
715
+ });
716
+
717
+ expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument();
718
+ expect(screen.getByTestId("add-backend-cloud-title")).toBeVisible();
719
+ expect(screen.getByTestId("add-backend-login-button")).toBeVisible();
720
+ expect(
721
+ screen.queryByTestId("onboarding-step-check-backend"),
722
+ ).not.toBeInTheDocument();
723
+ expect(
724
+ screen.queryByTestId("onboarding-progress-bar"),
725
+ ).not.toBeInTheDocument();
726
+ expect(screen.queryByTestId("add-backend-close")).not.toBeInTheDocument();
727
+ expect(
728
+ screen.queryByTestId("add-backend-advanced-toggle"),
729
+ ).not.toBeInTheDocument();
730
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
731
+ });
732
+
733
+ it("redirects unauthenticated locked-cookie deployments to main app login", async () => {
734
+ const assign = vi.fn();
735
+ Object.defineProperty(window, "location", {
736
+ configurable: true,
737
+ value: {
738
+ ...ORIGINAL_LOCATION,
739
+ origin: "https://pr-254.staging.openhands.dev",
740
+ hostname: "pr-254.staging.openhands.dev",
741
+ pathname: "/canvas",
742
+ search: "?tab=home",
743
+ hash: "#top",
744
+ assign,
745
+ },
746
+ });
747
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://pr-254.staging.all-hands.dev");
748
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
749
+ delete (window as unknown as Record<string, unknown>)
750
+ .__AGENT_CANVAS_SESSION_API_KEY__;
751
+ server.use(
752
+ http.post("*/api/authenticate", () =>
753
+ HttpResponse.json({ error: "unauthenticated" }, { status: 401 }),
754
+ ),
755
+ );
756
+ __resetActiveStoreForTests();
757
+
758
+ renderApp(["/"]);
759
+
760
+ await waitFor(() => {
761
+ expect(assign).toHaveBeenCalledWith(
762
+ "/login?returnTo=%2Fcanvas%3Ftab%3Dhome%23top",
763
+ );
764
+ });
765
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
766
+ });
767
+
768
+ it("redirects to main app login when the cookie session expires after the Canvas loaded", async () => {
769
+ // Arrange: the session is valid at load, then expires β€” the Cloud probe
770
+ // starts returning 401 and the next main-app auth check confirms it.
771
+ const assign = mockLockedCookieDeployment();
772
+ let sessionExpired = false;
773
+ server.use(
774
+ http.post("*/api/authenticate", () =>
775
+ sessionExpired
776
+ ? HttpResponse.json({ error: "unauthenticated" }, { status: 401 })
777
+ : HttpResponse.json({ ok: true }),
778
+ ),
779
+ http.get(`${COOKIE_DEPLOYMENT_ORIGIN}/api/organizations`, () => {
780
+ sessionExpired = true;
781
+ return HttpResponse.json(
782
+ { detail: "Not authenticated" },
783
+ { status: 401 },
784
+ );
785
+ }),
786
+ );
787
+
788
+ // Act
789
+ renderApp(["/"]);
790
+
791
+ // Assert: main-app login, not the device-flow recovery modal.
792
+ await waitFor(() => {
793
+ expect(assign).toHaveBeenCalledWith("/login?returnTo=%2Fcanvas");
794
+ });
795
+ expect(
796
+ screen.queryByTestId("manage-backends-modal"),
797
+ ).not.toBeInTheDocument();
798
+ expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument();
799
+ });
800
+
801
+ it("keeps a valid cookie session on the app when a stale logged-out health entry is persisted", async () => {
802
+ // Arrange: a previous visit left the locked backend persisted as disabled
803
+ // and "Logged out", but the user has since logged back in on the main app.
804
+ const assign = mockLockedCookieDeployment();
805
+ window.localStorage.setItem(
806
+ BACKEND_HEALTH_STORAGE_KEY,
807
+ JSON.stringify({
808
+ [LOCKED_CLOUD_BACKEND_ID]: {
809
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
810
+ lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR,
811
+ lastFailureAt: 1,
812
+ disabled: true,
813
+ },
814
+ }),
815
+ );
816
+ __resetHealthStoreForTests();
817
+ server.use(
818
+ http.get(`${COOKIE_DEPLOYMENT_ORIGIN}/api/organizations`, () =>
819
+ HttpResponse.json({ items: [], current_org_id: null }),
820
+ ),
821
+ );
822
+
823
+ // Act
824
+ renderApp(["/"]);
825
+
826
+ // Assert: the stale health verdict must not bounce a valid session to
827
+ // /login (which would loop back via returnTo); the app renders instead.
828
+ await waitFor(() => {
829
+ expect(screen.getByTestId("app-outlet")).toBeInTheDocument();
830
+ });
831
+ expect(assign).not.toHaveBeenCalled();
832
+ });
833
+
834
+ it("hides first-run onboarding immediately after Cloud login completes in locked-to-Cloud mode (no flicker)", async () => {
835
+ // Regression for hieptl's flicker report on PR #1389: after Cloud
836
+ // login succeeds in locked-to-Cloud mode, the onboarding modal's
837
+ // onClose marks onboarding complete. The root first-run gate must
838
+ // honor that completion IMMEDIATELY β€” without waiting for the Cloud
839
+ // settings probe to confirm a configured LLM β€” so the first-run
840
+ // screen disappears and the routed app renders, rather than the
841
+ // modal flickering back via OnboardingHost. This test simulates the
842
+ // post-login state (active locked Cloud backend + completion flag
843
+ // set by the modal's onClose) with the Cloud settings probe
844
+ // reporting NO configured LLM, which is exactly the window where
845
+ // the old LLM-readiness gate kept the first-run screen mounted and
846
+ // caused the reopen.
847
+ vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev");
848
+ vi.stubEnv("VITE_SESSION_API_KEY", "");
849
+ delete (window as unknown as Record<string, unknown>)
850
+ .__AGENT_CANVAS_SESSION_API_KEY__;
851
+ const lockedCloud = {
852
+ id: "locked-cloud",
853
+ name: "OpenHands Cloud",
854
+ host: "https://app.all-hands.dev",
855
+ apiKey: "cloud-session-key",
856
+ kind: "cloud",
857
+ };
858
+ window.localStorage.setItem(
859
+ "openhands-backends",
860
+ JSON.stringify([lockedCloud]),
861
+ );
862
+ window.localStorage.setItem(
863
+ "openhands-active-backend",
864
+ JSON.stringify({ backendId: lockedCloud.id, orgId: null }),
865
+ );
866
+ // The onboarding modal's onClose (markCompleted) sets this right
867
+ // after Cloud login succeeds β€” before the Cloud settings probe
868
+ // resolves. Seed it to reproduce the post-login moment.
869
+ window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1");
870
+ __resetActiveStoreForTests();
871
+ // Cloud settings probe reports no configured LLM. The completed
872
+ // onboarding flag should still hide first-run onboarding once the
873
+ // locked Cloud backend is active.
874
+ server.use(
875
+ http.get("https://app.all-hands.dev/api/v1/settings", () =>
876
+ HttpResponse.json({ llm_api_key_set: false }),
877
+ ),
878
+ http.get("https://app.all-hands.dev/api/keys/current", () =>
879
+ HttpResponse.json({ org_id: "org-1" }),
880
+ ),
881
+ );
882
+
883
+ renderApp(["/"]);
884
+
885
+ // The first-run onboarding screen must NOT be mounted (no reopen),
886
+ // and the routed app must render instead.
887
+ await waitFor(() => {
888
+ expect(screen.getByTestId("app-outlet")).toBeInTheDocument();
889
+ });
890
+ expect(
891
+ screen.queryByTestId("first-run-onboarding-screen"),
892
+ ).not.toBeInTheDocument();
893
+ expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument();
894
+ });
895
+ });
896
+
897
+ describe("App root document links", () => {
898
+ it("declares the SVG favicon used by the browser tab", () => {
899
+ // Act
900
+ const documentLinks = links();
901
+
902
+ // Assert
903
+ expect(documentLinks).toContainEqual({
904
+ rel: "icon",
905
+ type: "image/svg+xml",
906
+ href: "/favicon.svg",
907
+ });
908
+ });
909
+
910
+ it("prefixes document links when Canvas is mounted under a base path", () => {
911
+ // Arrange
912
+ vi.stubEnv("VITE_BASE_PATH", "/canvas");
913
+
914
+ // Act
915
+ const documentLinks = links();
916
+
917
+ // Assert
918
+ expect(documentLinks).toContainEqual({
919
+ rel: "icon",
920
+ type: "image/svg+xml",
921
+ href: "/canvas/favicon.svg",
922
+ });
923
+
924
+ vi.unstubAllEnvs();
925
+ });
926
+ });
__tests__/router.md ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Testing with React Router
2
+
3
+ ## Overview
4
+
5
+ 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.
6
+
7
+ This guide covers the two main approaches used in the OpenHands frontend:
8
+
9
+ 1. **`createRoutesStub`** - Creates a complete route structure for testing components with their actual route configuration, loaders, and nested routes.
10
+ 2. **`MemoryRouter`** - Provides a minimal routing context for components that just need router hooks to work.
11
+
12
+ Choose your approach based on what your component actually needs from the router.
13
+
14
+ ## When to Use Each Approach
15
+
16
+ ### `createRoutesStub` (Recommended)
17
+
18
+ Use `createRoutesStub` when your component:
19
+ - Relies on route parameters (`useParams`)
20
+ - Uses loader data (`useLoaderData`) or `clientLoader`
21
+ - Has nested routes or uses `<Outlet />`
22
+ - Needs to test navigation between routes
23
+
24
+ > [!NOTE]
25
+ > `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.
26
+
27
+ ```typescript
28
+ import { createRoutesStub } from "react-router";
29
+ import { render } from "@testing-library/react";
30
+
31
+ const RouterStub = createRoutesStub([
32
+ {
33
+ Component: MyRouteComponent,
34
+ path: "/conversations/:conversationId",
35
+ },
36
+ ]);
37
+
38
+ render(<RouterStub initialEntries={["/conversations/123"]} />);
39
+ ```
40
+
41
+ **With nested routes and loaders:**
42
+
43
+ ```typescript
44
+ const RouterStub = createRoutesStub([
45
+ {
46
+ Component: SettingsScreen,
47
+ clientLoader,
48
+ path: "/settings",
49
+ children: [
50
+ {
51
+ Component: () => <div data-testid="llm-settings" />,
52
+ path: "/settings",
53
+ },
54
+ {
55
+ Component: () => <div data-testid="mcp-settings" />,
56
+ path: "/settings/mcp",
57
+ },
58
+ ],
59
+ },
60
+ ]);
61
+
62
+ render(<RouterStub initialEntries={["/settings/mcp"]} />);
63
+ ```
64
+
65
+ > [!TIP]
66
+ > When using `clientLoader` from a Route module, you may encounter type mismatches. Use `@ts-expect-error` as a workaround:
67
+
68
+ ```typescript
69
+ import { clientLoader } from "@/routes/settings";
70
+
71
+ const RouterStub = createRoutesStub([
72
+ {
73
+ path: "/settings",
74
+ Component: SettingsScreen,
75
+ // @ts-expect-error: loader types won't align between test and app code
76
+ loader: clientLoader,
77
+ },
78
+ ]);
79
+ ```
80
+
81
+ ### `MemoryRouter`
82
+
83
+ Use `MemoryRouter` when your component:
84
+ - Only needs basic routing context to render
85
+ - Uses `<Link>` components but you don't need to test navigation
86
+ - Doesn't depend on specific route parameters or loaders
87
+
88
+ ```typescript
89
+ import { MemoryRouter } from "react-router";
90
+ import { render } from "@testing-library/react";
91
+
92
+ render(
93
+ <MemoryRouter>
94
+ <MyComponent />
95
+ </MemoryRouter>
96
+ );
97
+ ```
98
+
99
+ **With initial route:**
100
+
101
+ ```typescript
102
+ render(
103
+ <MemoryRouter initialEntries={["/some/path"]}>
104
+ <MyComponent />
105
+ </MemoryRouter>
106
+ );
107
+ ```
108
+
109
+ ## Anti-patterns to Avoid
110
+
111
+ ### Using `BrowserRouter` in tests
112
+
113
+ `BrowserRouter` interacts with the actual browser history API, which can cause issues in test environments:
114
+
115
+ ```typescript
116
+ // ❌ Avoid
117
+ render(
118
+ <BrowserRouter>
119
+ <MyComponent />
120
+ </BrowserRouter>
121
+ );
122
+
123
+ // βœ… Use MemoryRouter instead
124
+ render(
125
+ <MemoryRouter>
126
+ <MyComponent />
127
+ </MemoryRouter>
128
+ );
129
+ ```
130
+
131
+ ### Mocking router hooks when `createRoutesStub` would work
132
+
133
+ Mocking hooks like `useParams` directly can be brittle and doesn't test the actual routing behavior:
134
+
135
+ ```typescript
136
+ // ❌ Avoid when possible
137
+ vi.mock("react-router", async () => {
138
+ const actual = await vi.importActual("react-router");
139
+ return {
140
+ ...actual,
141
+ useParams: () => ({ conversationId: "123" }),
142
+ };
143
+ });
144
+
145
+ // βœ… Prefer createRoutesStub - tests real routing behavior
146
+ const RouterStub = createRoutesStub([
147
+ {
148
+ Component: MyComponent,
149
+ path: "/conversations/:conversationId",
150
+ },
151
+ ]);
152
+
153
+ render(<RouterStub initialEntries={["/conversations/123"]} />);
154
+ ```
155
+
156
+ ## Common Patterns
157
+
158
+ ### Combining with `QueryClientProvider`
159
+
160
+ Many components need both routing and TanStack Query context:
161
+
162
+ ```typescript
163
+ import { createRoutesStub } from "react-router";
164
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
165
+
166
+ const queryClient = new QueryClient({
167
+ defaultOptions: {
168
+ queries: { retry: false },
169
+ },
170
+ });
171
+
172
+ const RouterStub = createRoutesStub([
173
+ {
174
+ Component: MyComponent,
175
+ path: "/",
176
+ },
177
+ ]);
178
+
179
+ render(<RouterStub />, {
180
+ wrapper: ({ children }) => (
181
+ <QueryClientProvider client={queryClient}>
182
+ {children}
183
+ </QueryClientProvider>
184
+ ),
185
+ });
186
+ ```
187
+
188
+ ### Testing navigation behavior
189
+
190
+ Verify that user interactions trigger the expected navigation:
191
+
192
+ ```typescript
193
+ import { createRoutesStub } from "react-router";
194
+ import { screen } from "@testing-library/react";
195
+ import userEvent from "@testing-library/user-event";
196
+
197
+ const RouterStub = createRoutesStub([
198
+ {
199
+ Component: HomeScreen,
200
+ path: "/",
201
+ },
202
+ {
203
+ Component: () => <div data-testid="settings-screen" />,
204
+ path: "/settings",
205
+ },
206
+ ]);
207
+
208
+ render(<RouterStub initialEntries={["/"]} />);
209
+
210
+ const user = userEvent.setup();
211
+ await user.click(screen.getByRole("link", { name: /settings/i }));
212
+
213
+ expect(screen.getByTestId("settings-screen")).toBeInTheDocument();
214
+ ```
215
+
216
+ ## See Also
217
+
218
+ ### Codebase Examples
219
+
220
+ - [settings.test.tsx](routes/settings.test.tsx) - `createRoutesStub` with nested routes and loaders
221
+ - [root-layout.test.tsx](routes/root-layout.test.tsx) - `createRoutesStub` with `initialEntries` navigation
222
+ - [chat-interface.test.tsx](components/chat/chat-interface.test.tsx) - `MemoryRouter` usage
223
+
224
+ ### Official Documentation
225
+
226
+ - [React Router Testing Guide](https://reactrouter.com/start/framework/testing) - Official guide on testing with `createRoutesStub`
227
+ - [MemoryRouter API](https://reactrouter.com/api/declarative-routers/MemoryRouter) - API reference for `MemoryRouter`
__tests__/settings-schema-descriptions.test.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
3
+
4
+ describe("settings schema descriptions", () => {
5
+ it("provides helper descriptions for every schema-driven settings field", () => {
6
+ const schemas = [
7
+ MOCK_DEFAULT_USER_SETTINGS.agent_settings_schema,
8
+ MOCK_DEFAULT_USER_SETTINGS.conversation_settings_schema,
9
+ ].filter((schema): schema is NonNullable<typeof schema> => Boolean(schema));
10
+
11
+ const missingDescriptions = schemas.flatMap((schema) =>
12
+ schema.sections.flatMap((section) =>
13
+ section.fields
14
+ .filter((field) => !field.description?.trim())
15
+ .map((field) => field.key),
16
+ ),
17
+ );
18
+
19
+ expect(missingDescriptions).toEqual([]);
20
+ });
21
+ });
__tests__/vite-config.test.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @vitest-environment node
2
+ import viteConfig from "../vite.config";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ afterEach(() => {
6
+ delete process.env.BUILD_LIB;
7
+ });
8
+
9
+ describe("vite optimizeDeps", () => {
10
+ it("prebundles core client entry dependencies", async () => {
11
+ const config = await viteConfig({ mode: "development", command: "serve" });
12
+ const optimizedDeps = config.optimizeDeps?.include ?? [];
13
+
14
+ expect(optimizedDeps).toEqual(
15
+ expect.arrayContaining([
16
+ "react",
17
+ "react/jsx-runtime",
18
+ "react-dom/client",
19
+ "react-router/dom",
20
+ ]),
21
+ );
22
+ });
23
+ });
24
+
25
+ describe("vite path resolution", () => {
26
+ it("uses Vite's native tsconfig paths support", async () => {
27
+ const config = await viteConfig({ mode: "development", command: "serve" });
28
+
29
+ expect(config.resolve?.tsconfigPaths).toBe(true);
30
+ expect(config.plugins).not.toEqual(
31
+ expect.arrayContaining([
32
+ expect.objectContaining({ name: "vite-tsconfig-paths" }),
33
+ ]),
34
+ );
35
+ });
36
+ });
37
+
38
+ describe("vite app build", () => {
39
+ it("configures Rolldown code splitting for large vendor chunks", async () => {
40
+ const config = await viteConfig({ mode: "production", command: "build" });
41
+ const appBuild = config as {
42
+ build?: {
43
+ rolldownOptions?: {
44
+ output?: {
45
+ codeSplitting?: {
46
+ groups?: Array<{
47
+ name?: string;
48
+ maxSize?: number;
49
+ entriesAware?: boolean;
50
+ }>;
51
+ };
52
+ };
53
+ };
54
+ };
55
+ };
56
+
57
+ expect(appBuild.build?.rolldownOptions?.output?.codeSplitting?.groups).toEqual(
58
+ expect.arrayContaining([
59
+ expect.objectContaining({
60
+ name: "vendor",
61
+ maxSize: 450 * 1024,
62
+ entriesAware: true,
63
+ }),
64
+ ]),
65
+ );
66
+ });
67
+ });
68
+
69
+ describe("vite library build", () => {
70
+ it("configures a dual-format preserved-module library build", async () => {
71
+ process.env.BUILD_LIB = "true";
72
+
73
+ const config = await viteConfig({ mode: "production", command: "build" });
74
+
75
+ expect((config as { copyPublicDir?: boolean }).copyPublicDir).toBe(false);
76
+ expect(config.build?.lib).toMatchObject({
77
+ formats: ["es"],
78
+ });
79
+ expect(config.build?.rollupOptions?.external).toEqual(
80
+ expect.arrayContaining(["react", "react-dom", "react-router"]),
81
+ );
82
+ expect(config.build?.rollupOptions?.output).toEqual(
83
+ expect.arrayContaining([
84
+ expect.objectContaining({
85
+ format: "es",
86
+ preserveModules: true,
87
+ preserveModulesRoot: "src",
88
+ }),
89
+ expect.objectContaining({
90
+ format: "cjs",
91
+ preserveModules: true,
92
+ preserveModulesRoot: "src",
93
+ exports: "named",
94
+ }),
95
+ ]),
96
+ );
97
+ });
98
+ });
__tests__/vitest-setup-progress-event.test.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * Regression test for the `ReferenceError: ProgressEvent is not defined`
5
+ * unhandled rejection that intermittently failed whole CI runs
6
+ * (all tests green, exit code 1).
7
+ *
8
+ * MSW's XMLHttpRequest interceptor evaluates the bare `ProgressEvent`
9
+ * identifier inside async response callbacks. Vitest's jsdom teardown does
10
+ * `keys.forEach((key) => delete global[key])`, so any *own* property named
11
+ * `ProgressEvent` β€” jsdom's, or a polyfill a setup file installed β€” is gone
12
+ * once the environment for a test file is torn down. A callback that settles
13
+ * after that point then throws.
14
+ *
15
+ * `vitest.setup.ts` therefore keeps a fallback on `globalThis`'s prototype
16
+ * chain, which `delete` cannot reach. This test performs exactly the deletion
17
+ * teardown performs and asserts the identifier still resolves.
18
+ */
19
+ describe("ProgressEvent fallback in vitest.setup.ts", () => {
20
+ it("resolves the bare identifier after teardown deletes the own property", () => {
21
+ const live = Object.getOwnPropertyDescriptor(globalThis, "ProgressEvent");
22
+ expect(live).toBeDefined();
23
+
24
+ // What vitest's jsdom teardown does to every jsdom key.
25
+ delete (globalThis as { ProgressEvent?: unknown }).ProgressEvent;
26
+
27
+ try {
28
+ // Before the fix this is "undefined", and the construction below throws
29
+ // ReferenceError β€” the exact failure seen in CI.
30
+ expect(typeof ProgressEvent).toBe("function");
31
+
32
+ const event = new ProgressEvent("error", {
33
+ lengthComputable: true,
34
+ loaded: 3,
35
+ total: 7,
36
+ });
37
+
38
+ expect(event).toBeInstanceOf(Event);
39
+ expect(event.type).toBe("error");
40
+ expect(event.lengthComputable).toBe(true);
41
+ expect(event.loaded).toBe(3);
42
+ expect(event.total).toBe(7);
43
+ } finally {
44
+ if (live) Object.defineProperty(globalThis, "ProgressEvent", live);
45
+ }
46
+ });
47
+
48
+ it("prefers jsdom's own ProgressEvent while the environment is alive", () => {
49
+ // The own property shadows the prototype fallback, so nothing observes the
50
+ // stand-in until teardown removes jsdom's class.
51
+ expect(
52
+ Object.getOwnPropertyDescriptor(globalThis, "ProgressEvent"),
53
+ ).toBeDefined();
54
+ expect(new ProgressEvent("progress").type).toBe("progress");
55
+ });
56
+
57
+ it("does not add ProgressEvent to plain objects", () => {
58
+ // The fallback lives on globalThis's own prototype chain, which in Node is
59
+ // not Object.prototype β€” so it must not leak onto ordinary objects.
60
+ expect("ProgressEvent" in {}).toBe(false);
61
+ });
62
+ });
docs/ACP_AGENTS.md ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Using ACP agents
2
+
3
+ Agent Canvas can drive your conversations with the built-in **OpenHands** agent or
4
+ with an external **ACP agent** β€” Claude Code, Codex, or Gemini CLI. This guide
5
+ explains what ACP agents are, how to onboard one, and how to switch agents or
6
+ models later.
7
+
8
+ ## What is an ACP agent?
9
+
10
+ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com/protocol/overview)
11
+ is a standard for talking to coding agents over JSON-RPC on stdio. Instead of
12
+ Agent Canvas calling an LLM directly, the Agent Server spawns the agent's own CLI
13
+ as a subprocess and relays each turn to it. The external agent manages its own
14
+ LLM, tools, and execution; Agent Canvas sends messages and renders what comes
15
+ back.
16
+
17
+ ```mermaid
18
+ flowchart LR
19
+ canvas["Agent Canvas<br/>(this UI)"]
20
+ server["Agent Server"]
21
+ acp["ACP subprocess<br/>(e.g. claude-agent-acp)"]
22
+ llm["LLM provider<br/>(Anthropic / OpenAI / Google)"]
23
+ canvas -- "PATCH /api/settings<br/>(agent_kind, acp_*)" --> server
24
+ canvas -- "conversation turns" --> server
25
+ server -- "spawn + JSON-RPC over stdio" --> acp
26
+ acp -- "API calls" --> llm
27
+ ```
28
+
29
+ The Agent Server owns the subprocess and the credentials; Agent Canvas only
30
+ records *which* agent to run and surfaces a form for the secrets it needs. The
31
+ agent choice is stored per backend, so switching backends can switch agents.
32
+
33
+ ## Supported providers
34
+
35
+ The provider list is sourced from the SDK registry
36
+ (`openhands.sdk.settings.acp_providers`, mirrored into
37
+ `@openhands/typescript-client`) and enriched with Canvas UI metadata in
38
+ [`src/constants/acp-providers.ts`](../src/constants/acp-providers.ts). Adding or
39
+ changing a provider happens upstream in the SDK, not here.
40
+
41
+ | Provider | Default command |
42
+ |---|---|
43
+ | **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp` |
44
+ | **Codex** | `npx -y @agentclientprotocol/codex-acp` |
45
+ | **Gemini CLI** | `npx -y @google/gemini-cli --acp` |
46
+
47
+ See [Authentication](#authentication) for how each one authenticates.
48
+
49
+ ## Authentication
50
+
51
+ > [!IMPORTANT]
52
+ > ACP agents authenticate **two ways: a subscription login, or an API key** β€” and
53
+ > the onboarding fields are optional. If you're already signed in to the
54
+ > provider's CLI on the machine the agent runs on, it reuses that login
55
+ > automatically, so locally you often don't need a key at all. **The login takes
56
+ > priority over an API key:** while you're signed in, a key set in the
57
+ > environment isn't used β€” so the onboarding key fields do nothing and can be
58
+ > left blank.
59
+
60
+ A "subscription login" is the credential the provider's own CLI stores when you
61
+ sign in once β€” a file in your home directory, or, for Claude Code on macOS, the
62
+ system **Keychain**. When the Agent Server runs **on that same machine** (a local
63
+ or self-hosted backend), the provider CLI finds that login automatically β€” no API
64
+ key required. On a clean cloud sandbox there's no stored login, so an API key is
65
+ needed instead.
66
+
67
+ | Provider | Subscription login (auto-detected) | API key |
68
+ |---|---|---|
69
+ | **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)* |
70
+ | **Codex** | A ChatGPT login (`codex login`) cached at `~/.codex/auth.json` | `OPENAI_API_KEY` *(onboarding)* |
71
+ | **Gemini CLI** | Your Google login (`gemini`/`gemini --acp`) cached at `~/.gemini/oauth_creds.json` | `GEMINI_API_KEY` *(onboarding)* |
72
+
73
+ All three collect an *optional* API key (+ base URL) in onboarding. As noted
74
+ above, **a subscription / OAuth login takes priority over an API key** β€” when the
75
+ provider's CLI is signed in, a key set in the environment is not used. Verified
76
+ per provider:
77
+
78
+ - **Codex** β€” `codex login status` keeps reporting the ChatGPT login even with
79
+ `OPENAI_API_KEY` set.
80
+ - **Gemini CLI** β€” uses the OAuth auth type chosen at `gemini` login;
81
+ `GEMINI_API_KEY` is only consulted if you switch the auth type. The free Google
82
+ login is the common no-key path locally β€” sign in once and it **just works**.
83
+ - **Claude Code** β€” with both present, `claude auth status` reports it is
84
+ authenticated via the subscription (`claude.ai`), not the key. The login is
85
+ auto-detected from the macOS Keychain (or `~/.claude/.credentials.json` on
86
+ Linux); `CLAUDE_CONFIG_DIR` is **not** required for it β€” it only relocates
87
+ Claude Code's config directory (settings/history, not the token; e.g. for
88
+ containers or multiple accounts) and signals the SDK to strip a conflicting
89
+ `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`.
90
+
91
+ The one exception is the **base URL** (`*_BASE_URL`): a custom value points the
92
+ CLI at a different endpoint (a proxy or gateway) and *does* take effect even
93
+ under a login β€” for Gemini it rides the ACP `gateway` param. It's an advanced
94
+ override, not needed for normal use.
95
+
96
+ ## Onboarding an ACP agent
97
+
98
+ First-time users get a four-step onboarding modal. To onboard an ACP agent:
99
+
100
+ 1. **Choose agent** β€” pick Claude Code, Codex, or Gemini CLI instead of
101
+ OpenHands. The choice is saved immediately to your backend's settings.
102
+ 2. **Check backend** β€” confirms Agent Canvas can reach the Agent Server.
103
+ 3. **Set up credentials** β€” enter the provider's credentials. Beyond the API
104
+ key (+ optional base URL), this step also collects the credentials a
105
+ *containerized* backend needs, since a fresh container has no host login:
106
+ - **Codex** β€” `CODEX_AUTH_JSON` (the contents of `~/.codex/auth.json`).
107
+ - **Claude Code** β€” `CLAUDE_CODE_OAUTH_TOKEN` (a Pro/Max OAuth token).
108
+ - **Gemini CLI** β€” `GOOGLE_APPLICATION_CREDENTIALS_JSON` (Vertex SA / ADC JSON)
109
+ plus `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, and
110
+ `GOOGLE_GENAI_USE_VERTEXAI`.
111
+
112
+ On a **local** backend the step is optional (a host login is reused
113
+ automatically); on a **Docker / cloud** backend it's **required**, because
114
+ there's no host login to fall back on. When the login probe detects an
115
+ existing session, the step shows a "you're already signed in" banner and
116
+ stays skippable.
117
+ 4. **Say hello** β€” creates your first conversation and closes the modal.
118
+
119
+ > [!NOTE]
120
+ > On a local backend every credential field is optional and the step is
121
+ > skippable. Leave a field blank to reuse a key already set on the backend, or to
122
+ > authenticate the agent through a subscription / OAuth login instead.
123
+
124
+ ### How credentials reach the agent
125
+
126
+ Each credential you enter is saved as a **global secret** whose name is exactly
127
+ the environment variable the Agent Server exports into the ACP subprocess (e.g.
128
+ `ANTHROPIC_API_KEY`). Saving in onboarding is identical to adding the secret
129
+ under **Settings β†’ Secrets**, where you can edit or remove it anytime. Keeping
130
+ the secret name equal to the env var is what makes a saved key actually reach the
131
+ provider CLI.
132
+
133
+ ## Running ACP agents in a Docker container
134
+
135
+ The walkthrough above assumes the Agent Server runs on your own machine, where
136
+ the provider CLIs reuse a host login. You can also run the Agent Server **in a
137
+ container** β€” Canvas drives it the same way, but since a fresh container has no
138
+ host login, you supply credentials through the UI and Canvas sends them inline
139
+ on the conversation start request.
140
+
141
+ A ready-to-run setup lives in
142
+ [`examples/acp-docker/`](../examples/acp-docker/) (`docker compose up`, then
143
+ point Canvas at it). In short:
144
+
145
+ ```bash
146
+ # 1. Agent Server in a container (CORS allows localhost, so the browser talks
147
+ # to it directly). The image pre-installs the ACP CLI wrappers. New
148
+ # canvas_ui_control calls use client_tools; the Python mount keeps
149
+ # pre-migration conversations loadable when persisted metadata imports
150
+ # canvas_ui_tool.
151
+ # Minimum image: 1.28.0-python (first compatible ACP provider/model protocol
152
+ # surface for current Canvas). Override SHA with a newer build.
153
+ docker run -d --name oh-acp -p 8010:8000 -v acp-data:/workspace \
154
+ -v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \
155
+ ghcr.io/openhands/agent-server:1.28.0-python
156
+
157
+ # 2. Canvas pointed at the container.
158
+ VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend
159
+ ```
160
+
161
+ ### How credentials reach a containerized agent
162
+
163
+ In onboarding's **Set up credentials** step, the credentials you enter are saved
164
+ as global secrets in the agent-server's secret store (as usual). The start
165
+ request then references each as a **`LookupSecret`** β€” uniformly for ACP and
166
+ non-ACP β€” and the agent-server resolves the value back from its own store at
167
+ spawn time. For ACP this resolution runs **off the event loop**
168
+ (software-agent-sdk#3510), so the loopback fetch does not self-deadlock. The
169
+ SDK's `acp_file_secrets` defaults then:
170
+
171
+ - materialise `CODEX_AUTH_JSON` back to `auth.json` under `CODEX_HOME` and point
172
+ Codex at it;
173
+ - materialise `GOOGLE_APPLICATION_CREDENTIALS_JSON` to a file referenced by
174
+ `GOOGLE_APPLICATION_CREDENTIALS` and route Gemini through Vertex AI;
175
+ - export the rest (`CLAUDE_CODE_OAUTH_TOKEN`, project/location, API keys) as env
176
+ vars for the CLI.
177
+
178
+ Canvas just sends the secrets β€” it does **not** hand-roll the file
179
+ materialisation. The `npx -y <pkg>` command is rewritten to the pinned
180
+ pre-installed binary inside the container by the SDK, so no command change is
181
+ needed.
182
+
183
+ > [!IMPORTANT]
184
+ > **Do not set `ANTHROPIC_BASE_URL` alongside the Claude OAuth token.** An
185
+ > inherited LiteLLM base URL silently breaks the token's bearer auth (it routes
186
+ > the request away from Anthropic). Canvas never derives a base-URL secret from
187
+ > your LLM settings β€” but a base URL you save yourself rides along on every
188
+ > start request like any other saved secret, which is why the credential forms
189
+ > warn when both are set. Only set it deliberately, and not with the OAuth path.
190
+
191
+ > [!IMPORTANT]
192
+ > **Gemini Vertex needs a fresh ADC.** Run `gcloud auth application-default login`
193
+ > before copying `~/.config/gcloud/application_default_credentials.json` β€” a stale
194
+ > token surfaces as `invalid_rapt`, which is a credential problem, not a Canvas
195
+ > bug.
196
+
197
+ > [!NOTE]
198
+ > **Pick a non-flash Gemini model.** gemini-cli 0.45.x re-resolves any `*-flash`
199
+ > model id at generation time to its *current default* flash (e.g.
200
+ > `gemini-2.5-flash` silently ran `gemini-3-flash`, which 404s on projects that
201
+ > don't serve it β€” software-agent-sdk#3532). Only a non-flash id sticks, so
202
+ > Canvas preselects `gemini-2.5-pro`. If a Gemini turn fails with
203
+ > `Publisher Model … was not found`, check the selected model isn't a flash id.
204
+
205
+ ### Per-conversation isolation
206
+
207
+ Concurrent same-provider conversations in one container share a HOME, so they can
208
+ race on the CLI's auth/config/lock files. The SDK supports opting into a
209
+ per-conversation data dir (`acp_isolate_data_dir`, software-agent-sdk#3492), but
210
+ the released `@openhands/typescript-client` does not yet expose it on
211
+ `ACPAgentSettings`, so Canvas can't send it without risking a validation error on
212
+ older servers. This is tracked as a follow-up (agent-canvas#1019); cloud
213
+ grouping isolation is separate (agent-canvas#1016).
214
+
215
+ ## Switching agent or model later
216
+
217
+ Open **Settings β†’ Agent** at any time:
218
+
219
+ - **Agent** β€” switch between **OpenHands** and **ACP**.
220
+ - **Preset** β€” pick a built-in provider (Claude Code, Codex, Gemini CLI) or
221
+ **Custom** to point at any other ACP server.
222
+ - **Command** β€” the command line used to spawn the subprocess. Selecting a preset
223
+ fills this in; editing it to match another preset re-detects that provider.
224
+ API keys are *not* entered here β€” they live in the Secrets panel.
225
+ - **Model** β€” choose a suggested model for the provider or enter a custom model
226
+ override. Built-in providers save a concrete model rather than leaving it
227
+ blank.
228
+
229
+ Saving writes an `agent_settings_diff` (`agent_kind`, `acp_server`,
230
+ `acp_command`, `acp_model`) to `PATCH /api/settings`. A running conversation
231
+ keeps the agent it started with; the new choice applies to conversations you
232
+ start afterward.
233
+
234
+ ## Custom ACP servers
235
+
236
+ Any stdio ACP server works: choose **Custom** in Settings β†’ Agent and enter its
237
+ launch command. Custom servers have no curated model list, so enter the model ID
238
+ the server expects (if any) as a custom model. Pass credentials by adding the
239
+ env vars the server reads as global secrets under **Settings β†’ Secrets**.
docs/CANVAS_EXTENSIONS_TESTING.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Canvas Extensions manual testing
2
+
3
+ Canvas Extensions can be exercised locally before the Agent Server implements
4
+ the `/api/canvas-extensions` endpoints. Canvas's existing MSW development mode
5
+ contains an in-memory implementation of the API and serves the checked-in demo
6
+ extension bundle through the same frontend service and runtime used in a real
7
+ deployment.
8
+
9
+ This path is for frontend development only. It does not test Agent Server
10
+ installation, filesystem validation, persistence, authentication, or Git
11
+ resolution.
12
+
13
+ ## Start the mock frontend
14
+
15
+ From the repository root, run:
16
+
17
+ ```sh
18
+ VITE_FRONTEND_PORT=3102 \
19
+ VITE_BACKEND_BASE_URL=http://127.0.0.1:8000 \
20
+ VITE_SESSION_API_KEY=canvas-extension-dev \
21
+ npm run dev:mock
22
+ ```
23
+
24
+ Port `3102` avoids the `3001` Vite process used by the normal local stack. The
25
+ backend URL only gives Canvas a local backend identity; MSW intercepts the
26
+ extension requests in the browser. The mock also covers the settings and server
27
+ information probes needed to mark that backend healthy, so the Agent Server
28
+ does not need the extension endpoints and does not need to be running.
29
+
30
+ Open <http://localhost:3102/extensions>. Do not use the normal ingress URL at
31
+ `http://localhost:8000` for this test because its `/api` traffic goes directly
32
+ to the unmodified Agent Server rather than through the mock browser session.
33
+
34
+ If the browser profile already contains incompatible backend or onboarding
35
+ state, use a private window or clear local storage for `localhost:3102` and
36
+ reload.
37
+
38
+ ## Install and enable the fixture
39
+
40
+ 1. In **Customize -> Extensions**, select **Add extension**.
41
+ 2. Enter this exact source:
42
+
43
+ ```text
44
+ src/fixtures/canvas-extensions/demo-page
45
+ ```
46
+
47
+ 3. Leave **Ref** and **Repository path** empty, then select **Install**.
48
+ 4. Confirm that **Demo page** appears disabled. Installation must not execute
49
+ the bundle or add its navigation item.
50
+ 5. Turn on the extension and accept the trusted-code confirmation.
51
+ 6. Confirm that **Extension demo** appears in the main left rail.
52
+ 7. Open it and verify the page says **Hello from a Canvas Extension**.
53
+ 8. Visit `/extensions/demo-page/hello/nested` directly and verify the page
54
+ renders `Nested extension path: nested`.
55
+
56
+ ## Lifecycle checks
57
+
58
+ - **Disable:** turn the extension off. Its rail item should disappear, and its
59
+ route should no longer render the contributed page.
60
+ - **Re-enable:** turn it on again. The item and page should return without a
61
+ Canvas restart.
62
+ - **Uninstall:** select **Uninstall** and confirm. The inventory and rail item
63
+ should become empty.
64
+ - **Reload:** reload the page and confirm the installation and enablement are
65
+ retained for this browser tab. The mock uses session storage and clears when
66
+ you uninstall it or end the browser session.
67
+
68
+ ## Test an extension edit
69
+
70
+ Edit
71
+ `src/fixtures/canvas-extensions/demo-page/extension.js`, restart the mock
72
+ frontend if Vite does not rebuild the raw fixture import automatically, then
73
+ uninstall and reinstall the fixture. This allows page mounting, cleanup,
74
+ subrouting, and use of the host API to be developed before backend support is
75
+ available.
76
+
77
+ The fixture must remain a self-contained browser ES module: it may not rely on
78
+ bare package imports or additional output chunks.
79
+
80
+ ## What still requires the Agent Server
81
+
82
+ Repeat this flow against `http://localhost:8000/extensions` after the backend
83
+ contract lands. That test must additionally verify:
84
+
85
+ - Git and backend-local-path installation;
86
+ - immutable revision resolution;
87
+ - manifest, traversal, symlink, and entrypoint validation;
88
+ - persistence across Agent Server and browser restarts;
89
+ - session-authenticated bundle delivery;
90
+ - isolation when switching between active backends.
91
+
92
+ The backend contract and acceptance criteria are documented in
93
+ [`specs/canvas-extensions.md`](../specs/canvas-extensions.md).
docs/DEVELOPMENT.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development
2
+
3
+ This document is for contributors working on `agent-canvas` itself.
4
+
5
+ ## Recommended local workflow
6
+
7
+ `npm run dev` runs the full local stack (agent-server + automation backend via
8
+ `uvx`, Vite dev server with live reload, and an ingress proxy) β€” all without
9
+ Docker.
10
+
11
+ ## Repository boundaries
12
+
13
+ This repository contains the Agent Canvas frontend and local-stack orchestration. Use the sibling repositories for their owned layers:
14
+
15
+ - [`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.
16
+ - [`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.
17
+ - [`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.
18
+
19
+ 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.
20
+
21
+
22
+ For a static frontend build (better for slow networks, remote access, tunnels):
23
+
24
+ ```sh
25
+ npm run dev:static
26
+ ```
27
+
28
+ The published `agent-canvas` binary also supports partial-stack modes when you want to run the frontend and backend processes separately:
29
+
30
+ ```sh
31
+ agent-canvas --frontend-only
32
+ agent-canvas --backend-only
33
+ ```
34
+
35
+ Both modes still start the ingress proxy; the proxy only routes to the services started by that mode.
36
+
37
+ The dev stack uses `uvx` to run a temporary `agent-server`
38
+ installation on `127.0.0.1:18000` and points the frontend at it. It isolates
39
+ conversation persistence by setting separate `OH_CONVERSATIONS_PATH`,
40
+ `OH_BASH_EVENTS_DIR`, and `OH_VSCODE_PORT` values under `.openhands-dev/`, and
41
+ keeps its tmux sockets under `~/.openhands/agent-canvas/tmux` (via
42
+ `TMUX_TMPDIR`), so it does not collide with other local or cloud-backed
43
+ OpenHands sessions. If `$HOME` is on a filesystem that does not support Unix
44
+ domain sockets (some devcontainers, NFS/CIFS homes), set the standard
45
+ `TMUX_TMPDIR` env var to a local path such as `/tmp` and the dev stack will use
46
+ it instead.
47
+
48
+ ### Environment Variables
49
+
50
+ | Variable | Description | Default |
51
+ | ------------------------- | ------------------------------ | ------- |
52
+ | `PORT` | Ingress port | `8000` |
53
+ | `OH_AUTOMATION_GIT_REF` | Git ref for automation backend (overrides the pinned default version) | *(unset)* |
54
+ | `OH_AGENT_SERVER_GIT_REF` | Git ref for agent-server (overrides the pinned default version) | *(unset)* |
55
+
56
+ ### Alternative: Minimal Mode (without Automation)
57
+
58
+ To run without the automation service:
59
+
60
+ ```sh
61
+ npm run dev:minimal
62
+ ```
63
+
64
+ This runs only agent-server + Vite (no automation backend or ingress).
65
+ Access at `http://localhost:3001/`
66
+
67
+ ### Agent server version selection
68
+
69
+ By default, the latest released version from PyPI is used. You can override this (highest precedence first):
70
+
71
+ ```sh
72
+ # Run against a local software-agent-sdk checkout.
73
+ OH_AGENT_SERVER_LOCAL_PATH=/abs/path/to/software-agent-sdk npm run dev
74
+
75
+ # Use a git branch or commit (takes precedence over version)
76
+ OH_AGENT_SERVER_GIT_REF=main npm run dev
77
+ OH_AGENT_SERVER_GIT_REF=abc1234 npm run dev
78
+
79
+ # Use a specific PyPI version
80
+ OH_AGENT_SERVER_VERSION=1.18.0 npm run dev
81
+ ```
82
+
83
+ `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.
84
+
85
+ ### Other useful overrides
86
+
87
+ - `OH_CANVAS_SAFE_BACKEND_PORT` β€” backend port for the isolated server (default `18000`)
88
+ - `OH_CANVAS_SAFE_VSCODE_PORT` β€” VS Code sidecar port (default `backend port + 1`)
89
+ - `OH_CANVAS_SAFE_STATE_DIR` β€” base directory for isolated server state
90
+ - `VITE_WORKING_DIR` β€” repo root used for new conversations (defaults to the current checkout)
91
+
92
+ ## Alternative development workflows
93
+
94
+ ### Multiple local backends (shared persistence)
95
+
96
+ To run a second standalone agent-server alongside `npm run dev` while sharing
97
+ its conversation history and encrypted secrets, you can use the
98
+ `npm run dev:extra-backend` helper. It launches an extra server on `:18002` that
99
+ reuses the bundled instance's state dir.
100
+
101
+ ### Frontend against an existing backend
102
+
103
+ Use this only if you intentionally started `agent-server` yourself or want the frontend to talk to another backend:
104
+
105
+ ```sh
106
+ npm run dev:frontend
107
+ ```
108
+
109
+ The frontend-only workflow expects the backend at `127.0.0.1:8000` by default.
110
+
111
+ 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.
112
+
113
+ ### Mock mode
114
+
115
+ If you want to run the frontend without a live backend, use:
116
+
117
+ ```sh
118
+ npm run dev:mock
119
+ ```
120
+
121
+ ## Build and test
122
+
123
+ ```sh
124
+ npm run test
125
+ npm run build
126
+ npm run start
127
+ ```
128
+
129
+ Useful targeted verification for the isolated dev launcher:
130
+
131
+ ```sh
132
+ npm run test -- __tests__/api/agent-server-config.test.ts __tests__/scripts/dev-safe.test.ts
133
+ ```
134
+
135
+ ### Mutation testing
136
+
137
+ Stryker checks whether the Vitest suite detects deliberate changes to the
138
+ first-party TypeScript source under `src/`. The default configuration excludes
139
+ tests, declarations, generated files, fixtures, mocks, and development seeds.
140
+
141
+ ```sh
142
+ # Full mutation run (expensive for the whole frontend)
143
+ npm run test:mutation
144
+
145
+ # Reuse results from the previous run
146
+ npm run test:mutation:incremental
147
+
148
+ # Mutate only production files changed from the local main branch
149
+ npm run test:mutation:diff
150
+
151
+ # Compare with another base ref, such as the latest remote main
152
+ npm run test:mutation:diff -- origin/main
153
+ ```
154
+
155
+ The HTML report is written to `reports/mutation.html`. Mutation scores are
156
+ report-only initially; establish a stable baseline before adding a failing
157
+ threshold.
158
+
159
+ Stryker does not cover the small Python surface in this repository; mutating it
160
+ would need a Python test harness and Python-specific mutation tool.
161
+
162
+ ## CSS isolation and host-app customization
163
+
164
+ 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.
165
+
166
+ ### Embedding strategy
167
+
168
+ - Use `AgentServerUIProviders` in host apps. It renders a scoped style root by default.
169
+ - For direct wrapper control, use `AgentServerUIRoot`.
170
+ - The standalone app opts out of the provider wrapper because the router layout already renders the scoped root.
171
+
172
+ ### Customization strategy
173
+
174
+ 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]`.
175
+
176
+ ```tsx
177
+ <AgentServerUIProviders
178
+ styleOverrides={{
179
+ "--oh-color-base": "#101820",
180
+ "--oh-color-content-2": "#f5f7ff",
181
+ "--oh-accent": "#8b5cf6",
182
+ }}
183
+ >
184
+ <App />
185
+ </AgentServerUIProviders>
186
+ ```
187
+
188
+ 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.
189
+
190
+ ## Environment variables
191
+
192
+ You can create a `.env` file in the project directory with these variables based on `.env.sample`.
193
+
194
+ | Variable | Description | Default Value |
195
+ | --------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- |
196
+ | `VITE_BACKEND_BASE_URL` | Full base URL for the agent server used by direct browser requests | current browser origin |
197
+ | `VITE_BACKEND_HOST` | Backend host used by the Vite dev proxy | `127.0.0.1:8000` |
198
+ | `VITE_SESSION_API_KEY` | (Internal) Session API key injected by the launcher β€” set `LOCAL_BACKEND_API_KEY` instead | - |
199
+ | `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` |
200
+ | `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` |
201
+ | `VITE_BASE_PATH` | Build/serve the SPA under a subpath such as `/canvas` | `/` |
202
+ | `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` |
203
+ | `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` |
204
+ | `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` |
205
+ | `VITE_INSECURE_SKIP_VERIFY` | Skip TLS certificate verification for proxied backend requests | `false` |
docs/DefenseClaw.md ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Integrating DefenseClaw with Agent Canvas
2
+
3
+ [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.
4
+
5
+ > **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.
6
+
7
+ ---
8
+
9
+ ## How the Two Systems Fit Together
10
+
11
+ ```mermaid
12
+ flowchart TD
13
+ UI["Agent Canvas (browser)"]
14
+ AS["OpenHands Agent Server\nlocalhost:18000"]
15
+ GP["DefenseClaw Guardrail Proxy\nlocalhost:4000"]
16
+ LLM["LLM Provider"]
17
+ GW["DefenseClaw Gateway Sidecar\nlocalhost:18970"]
18
+ CLI["DefenseClaw CLI / TUI"]
19
+
20
+ UI -->|HTTP| AS
21
+ AS -->|LLM API calls| GP
22
+ GP -->|forwarded request| LLM
23
+ GW <-->|REST API| AS
24
+ CLI <-->|REST API| GW
25
+
26
+ style GW fill:#fff3cd,stroke:#856404
27
+ style CLI fill:#fff3cd,stroke:#856404
28
+ style GP fill:#f8d7da,stroke:#842029
29
+ ```
30
+
31
+ **Shared concepts:**
32
+
33
+ | Agent Canvas / Agent Server | DefenseClaw equivalent |
34
+ |---|---|
35
+ | Skills (`.agents/skills/`) | Skills (scanned by `cisco-ai-skill-scanner` + CodeGuard) |
36
+ | MCP servers | MCP servers (scanned by `cisco-ai-mcp-scanner`) |
37
+ | LLM settings (`base_url`) | Guardrail proxy upstream target |
38
+ | Workspace files (generated code) | CodeGuard scan surface |
39
+ | Agent Server hooks | Potential enforcement point (future work) |
40
+
41
+ ---
42
+
43
+ ## Prerequisites
44
+
45
+ | Component | Version |
46
+ |---|---|
47
+ | Agent Canvas / Agent Server | Current `main` |
48
+ | Python | 3.10+ |
49
+ | Go | 1.26.2+ (for DefenseClaw gateway) |
50
+ | DefenseClaw | Latest release |
51
+
52
+ ---
53
+
54
+ ## Installation
55
+
56
+ ### 1. Install and initialise DefenseClaw
57
+
58
+ ```bash
59
+ # Install from the release script
60
+ curl -LsSf https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/scripts/install.sh | bash
61
+
62
+ # Initialise config and enable the guardrail proxy
63
+ defenseclaw init --enable-guardrail
64
+ ```
65
+
66
+ Verify the installation:
67
+
68
+ ```bash
69
+ defenseclaw doctor
70
+ ```
71
+
72
+ Start the Go gateway sidecar (keep this running alongside the Agent Server):
73
+
74
+ ```bash
75
+ defenseclaw-gateway start
76
+ ```
77
+
78
+ ### 2. Start Agent Canvas
79
+
80
+ Follow the standard [Agent Canvas quickstart](../README.md). The integration steps below assume the Agent Server is reachable at `http://localhost:18000`.
81
+
82
+ ---
83
+
84
+ ## Integration Points
85
+
86
+ ### A. Load the CodeGuard Skill
87
+
88
+ 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.).
89
+
90
+ **Install the skill into a user or project skill directory:**
91
+
92
+ ```bash
93
+ # User-level (applies to all Agent Server conversations on this machine)
94
+ mkdir -p ~/.agents/skills/codeguard
95
+ curl -fsSL https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/skills/codeguard/SKILL.md \
96
+ -o ~/.agents/skills/codeguard/SKILL.md
97
+
98
+ # Project-level (checked in alongside your project, only affects that workspace)
99
+ mkdir -p .agents/skills/codeguard
100
+ curl -fsSL https://raw.githubusercontent.com/cisco-ai-defense/defenseclaw/main/skills/codeguard/SKILL.md \
101
+ -o .agents/skills/codeguard/SKILL.md
102
+ ```
103
+
104
+ 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.
105
+
106
+ **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.
107
+
108
+ ---
109
+
110
+ ### B. Route LLM Traffic Through the Guardrail Proxy
111
+
112
+ 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).
113
+
114
+ **Configure the LLM base URL in Agent Canvas:**
115
+
116
+ Open the Agent Canvas settings panel β†’ select your active backend β†’ under **LLM settings**, set **Base URL** to:
117
+
118
+ ```
119
+ http://localhost:4000
120
+ ```
121
+
122
+ 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.
123
+
124
+ **Via environment variable (server-side):**
125
+
126
+ If you configure your Agent Server through environment variables, set the LLM base URL before starting it:
127
+
128
+ ```bash
129
+ # Example using OpenAI; set model and key as normal, only base_url changes
130
+ export OH_LLM__BASE_URL="http://localhost:4000"
131
+ npm run dev
132
+ ```
133
+
134
+ > 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.
135
+
136
+ **Start the guardrail in observe mode (safe default) or action mode:**
137
+
138
+ ```bash
139
+ # Observe β€” log findings, never block (recommended while tuning)
140
+ defenseclaw setup guardrail --mode observe --restart
141
+
142
+ # Action β€” block prompts and responses that match policies
143
+ defenseclaw setup guardrail --mode action --restart
144
+ ```
145
+
146
+ **Supported providers:**
147
+
148
+ 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.
149
+
150
+ ---
151
+
152
+ ### C. Scan Skills Before Loading
153
+
154
+ Before installing a skill from the marketplace or an external source into the Agent Server, use the DefenseClaw CLI to vet it:
155
+
156
+ ```bash
157
+ # Scan a locally downloaded skill directory
158
+ defenseclaw skill scan path/to/skill-directory
159
+
160
+ # Scan an installed skill by name (requires the skill to be registered in the DefenseClaw inventory)
161
+ defenseclaw skill scan my-skill-name
162
+
163
+ # List all skills currently visible to DefenseClaw
164
+ defenseclaw skill list
165
+ ```
166
+
167
+ 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.
168
+
169
+ **Workflow recommendation:** Add `defenseclaw skill scan <skill-dir>` as a pre-commit or CI step in repositories that ship skills for Agent Canvas.
170
+
171
+ ---
172
+
173
+ ### D. Scan Agent-Generated Code
174
+
175
+ After an agent conversation produces code in the workspace, run CodeGuard on the output before committing:
176
+
177
+ ```bash
178
+ # Scan an entire workspace directory
179
+ defenseclaw codeguard scan /path/to/workspace
180
+
181
+ # Scan a single file
182
+ defenseclaw codeguard scan /path/to/workspace/src/auth.py
183
+
184
+ # Output as JSON (useful in CI pipelines)
185
+ defenseclaw codeguard scan /path/to/workspace --json
186
+ ```
187
+
188
+ 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.
189
+
190
+ **Zero-friction CI gate example (GitHub Actions):**
191
+
192
+ ```yaml
193
+ - name: Scan agent-generated code
194
+ run: |
195
+ defenseclaw codeguard scan ${{ github.workspace }} --json \
196
+ | python3 -c "
197
+ import sys, json
198
+ findings = json.load(sys.stdin)
199
+ criticals = [f for f in findings if f.get('severity') in ('HIGH','CRITICAL')]
200
+ if criticals:
201
+ for f in criticals:
202
+ print(f'::error file={f[\"file\"]},line={f[\"line\"]}::{f[\"rule\"]}: {f[\"message\"]}')
203
+ sys.exit(1)
204
+ "
205
+ ```
206
+
207
+ ---
208
+
209
+ ### E. Monitor via the DefenseClaw TUI and Audit Store
210
+
211
+ 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:
212
+
213
+ ```bash
214
+ defenseclaw tui
215
+ ```
216
+
217
+ The TUI panels cover:
218
+ - **Alerts** β€” recent HIGH/CRITICAL findings and blocked events
219
+ - **Scans** β€” historical scan results per skill/file
220
+ - **Tools** β€” tool-call verdicts from the inspection engine
221
+ - **Policy** β€” current block/allow lists
222
+
223
+ **Export to external systems:**
224
+
225
+ | Target | Setup |
226
+ |---|---|
227
+ | OTLP (Prometheus/Grafana/Honeycomb) | `defenseclaw setup observability --otlp-endpoint http://collector:4317` |
228
+ | Splunk HEC | `defenseclaw setup splunk --hec-url http://splunk:8088 --hec-token $TOKEN` |
229
+ | Slack / PagerDuty / Webex | `defenseclaw setup notifications --slack-webhook $SLACK_URL` |
230
+ | Local Splunk bundle (Docker) | `defenseclaw setup splunk --logs --accept-splunk-license` |
231
+
232
+ ---
233
+
234
+ ## Integration Summary
235
+
236
+ | Goal | Mechanism | Config change? | Code change? |
237
+ |---|---|---|---|
238
+ | Agent writes secure code by default | CodeGuard skill in `.agents/skills/` | Drop-in file | No |
239
+ | Inspect all LLM prompts and responses | Guardrail proxy at `localhost:4000` | Set `base_url` | No |
240
+ | Vet skills before loading | `defenseclaw skill scan` in CI/workflow | None | No |
241
+ | Scan agent-generated code | `defenseclaw codeguard scan <workspace>` | None | No |
242
+ | Audit trail and alerting | DefenseClaw TUI, OTLP, Splunk, webhooks | DefenseClaw config | No |
243
+
244
+ ---
245
+
246
+ ## Future Work: Code-Level Extensions
247
+
248
+ The following integrations would require changes to Agent Canvas, the Agent Server, or DefenseClaw, but would significantly deepen the security posture.
249
+
250
+ ### 1. Native `SecurityAnalyzer` hook
251
+
252
+ 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.
253
+
254
+ ```python
255
+ # Sketch β€” not yet implemented
256
+ class DefenseClawSecurityAnalyzer(SecurityAnalyzer):
257
+ async def analyze(self, action: Action) -> ActionSecurityRisk:
258
+ resp = await httpx.post(
259
+ "http://localhost:18970/api/v1/inspect/tool",
260
+ json={"tool": action.tool_name, "args": action.args},
261
+ headers={"X-DefenseClaw-Client": "agent-server"},
262
+ )
263
+ if resp.json()["action"] == "block":
264
+ return ActionSecurityRisk.HIGH
265
+ return ActionSecurityRisk.LOW
266
+ ```
267
+
268
+ ### 2. Skill install pipeline integration
269
+
270
+ 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`.
271
+
272
+ ### 3. Hooks integration
273
+
274
+ The Agent Server loads `.openhands/hooks.json` from the workspace. An `on_conversation_end` hook that runs `defenseclaw codeguard scan <workspace>` and writes findings to a structured report file would give per-session security evidence without manual operator intervention.
275
+
276
+ ### 4. Agent Canvas security dashboard
277
+
278
+ 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.
279
+
280
+ ### 5. Agent Server β†’ DefenseClaw audit bridge
281
+
282
+ 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.
283
+
284
+ ### 6. Skill registry alignment
285
+
286
+ 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.
287
+
288
+ ---
289
+
290
+ ## References
291
+
292
+ - [DefenseClaw GitHub](https://github.com/cisco-ai-defense/defenseclaw)
293
+ - [DefenseClaw Quick Start](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/QUICKSTART.md)
294
+ - [DefenseClaw API Reference](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/API.md)
295
+ - [DefenseClaw Guardrail Architecture](https://github.com/cisco-ai-defense/defenseclaw/blob/main/docs/GUARDRAIL.md)
296
+ - [DefenseClaw CodeGuard Skill](https://github.com/cisco-ai-defense/defenseclaw/blob/main/skills/codeguard/SKILL.md)
297
+ - [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server)
298
+ - [OpenHands SDK Security Analyzer](https://docs.openhands.dev/sdk/arch/security.md)
299
+ - [Agent Canvas Self-Hosting](./SELF_HOSTING.md)
300
+
301
+ ---
302
+
303
+ _This document was created by an AI agent (OpenHands) on behalf of the user._
docs/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Canvas docs
2
+
3
+ This directory contains the project documentation.
4
+
5
+ - [Architecture](./architecture.md): system boundaries, runtime modes, and quality gates.
6
+ - [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI).
7
+ - [Development guide](./DEVELOPMENT.md)
8
+ - [Canvas Extensions manual testing](./CANVAS_EXTENSIONS_TESTING.md)
9
+ - [Self-hosting guide](./SELF_HOSTING.md)
10
+ - [Integrating DefenseClaw](./DefenseClaw.md): run the DefenseClaw security governance layer alongside the Agent Server.
11
+ - [Testing matrix](./TESTING_MATRIX.md): release smoke-test coverage across installers, operating systems, and agents.
electron/loading.html ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenHands Agent Canvas</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ /* Agent Canvas design tokens. The splash renders before the frontend is
11
+ served, so it cannot import the app's CSS β€” these values mirror the
12
+ static baseline in src/tailwind.css / src/index.css. Keep in sync. */
13
+ :root {
14
+ --oh-background: #0b0e14; /* cool-grey-950 */
15
+ --oh-foreground: #eef2f7; /* cool-grey-100 */
16
+ --oh-muted: #a3b0c4; /* cool-grey-400 */
17
+ --oh-text-dim: #7e8a9e; /* cool-grey-500 */
18
+ --oh-text-subtle: #626d82; /* cool-grey-600 */
19
+ --oh-border: #4b5468; /* cool-grey-700 */
20
+ --oh-border-subtle: #383f50; /* cool-grey-800 */
21
+ --oh-surface-deep: #05070a; /* cool-grey-975 */
22
+ --oh-color-danger: #e76a5e;
23
+ }
24
+
25
+ html, body {
26
+ width: 100%; height: 100%;
27
+ background: var(--oh-background);
28
+ font-family:
29
+ -apple-system, "SF Pro", BlinkMacSystemFont, "Segoe UI", "Roboto",
30
+ "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
31
+ "Helvetica Neue", sans-serif;
32
+ -webkit-font-smoothing: antialiased;
33
+ -moz-osx-font-smoothing: grayscale;
34
+ color: var(--oh-foreground);
35
+ user-select: none;
36
+ -webkit-app-region: drag;
37
+ overflow: hidden;
38
+ }
39
+
40
+ body {
41
+ display: flex;
42
+ flex-direction: column;
43
+ }
44
+
45
+ .container {
46
+ display: flex;
47
+ flex-direction: column;
48
+ align-items: center;
49
+ justify-content: center;
50
+ /* Must equal the collapsed window height (LOADING_WIN_HEIGHT in
51
+ main.mjs): the block keeps its layout when the window grows to
52
+ reveal the startup-log console below it. */
53
+ height: 360px;
54
+ flex: none;
55
+ gap: 0;
56
+ }
57
+
58
+ /* Real app icon β€” squircle corners are baked into the PNG. Kept inert so
59
+ image dragging cannot hijack the -webkit-app-region window drag. */
60
+ .logo {
61
+ width: 72px;
62
+ height: 72px;
63
+ margin-bottom: 20px;
64
+ -webkit-user-drag: none;
65
+ }
66
+
67
+ h1 {
68
+ font-size: 22px;
69
+ font-weight: 600;
70
+ letter-spacing: -0.3px;
71
+ margin-bottom: 8px;
72
+ color: var(--oh-foreground);
73
+ }
74
+
75
+ .tagline {
76
+ font-size: 13px;
77
+ color: var(--oh-muted);
78
+ margin-bottom: 36px;
79
+ }
80
+
81
+ /* The app's canonical loading indicator: the 270Β° arc from
82
+ src/icons/loading-outer.svg with LoadingSpinner's animate-spin timing. */
83
+ .spinner {
84
+ width: 25px;
85
+ height: 25px;
86
+ color: #fff;
87
+ animation: spin 1s linear infinite;
88
+ margin-bottom: 14px;
89
+ }
90
+ @keyframes spin {
91
+ to { transform: rotate(360deg); }
92
+ }
93
+
94
+ .status {
95
+ font-size: 12px;
96
+ color: var(--oh-text-dim);
97
+ letter-spacing: 0.3px;
98
+ /* Long lines (e.g. uvx "Installing openhands-agent-server==1.24.0…")
99
+ must not push the window or wrap awkwardly. */
100
+ max-width: 360px;
101
+ text-align: center;
102
+ white-space: nowrap;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ }
106
+
107
+ .hint {
108
+ margin-top: 14px;
109
+ font-size: 11px;
110
+ color: var(--oh-text-subtle);
111
+ letter-spacing: 0.2px;
112
+ max-width: 340px;
113
+ text-align: center;
114
+ line-height: 1.5;
115
+ }
116
+
117
+ /* Progress dots animation for status text */
118
+ .dots::after {
119
+ content: "";
120
+ animation: dots 1.5s steps(3, end) infinite;
121
+ }
122
+ @keyframes dots {
123
+ 0% { content: ""; }
124
+ 33% { content: "."; }
125
+ 66% { content: ".."; }
126
+ 100% { content: "..."; }
127
+ }
128
+
129
+ .actions {
130
+ margin-top: 16px;
131
+ display: flex;
132
+ gap: 10px;
133
+ -webkit-app-region: no-drag;
134
+ }
135
+
136
+ .ghost-button {
137
+ font: inherit;
138
+ font-size: 11px;
139
+ padding: 4px 12px;
140
+ color: var(--oh-muted);
141
+ background: transparent;
142
+ border: 1px solid var(--oh-border);
143
+ border-radius: 8px;
144
+ cursor: pointer;
145
+ -webkit-app-region: no-drag;
146
+ }
147
+ .ghost-button:hover {
148
+ color: var(--oh-foreground);
149
+ background: rgba(255, 255, 255, 0.06);
150
+ }
151
+
152
+ /* ── Startup-log console ─────────────────────────────────────────────
153
+ Fills the extra height revealed by "Show details" (the window grows β€”
154
+ see setLoadingWindowExpanded in main.mjs); zero-height and clipped
155
+ while the window is collapsed. Interactive: opts out of the window
156
+ drag region and re-enables text selection. */
157
+ .console {
158
+ flex: 1 1 auto;
159
+ min-height: 0;
160
+ display: flex;
161
+ flex-direction: column;
162
+ margin: 0 16px 16px;
163
+ background: var(--oh-surface-deep);
164
+ border: 1px solid var(--oh-border-subtle);
165
+ border-radius: 8px;
166
+ overflow: hidden;
167
+ -webkit-app-region: no-drag;
168
+ }
169
+
170
+ .console-head {
171
+ flex: none;
172
+ display: flex;
173
+ align-items: center;
174
+ justify-content: space-between;
175
+ padding: 5px 6px 5px 12px;
176
+ border-bottom: 1px solid var(--oh-border-subtle);
177
+ font-size: 11px;
178
+ color: var(--oh-muted);
179
+ }
180
+
181
+ .log {
182
+ flex: 1 1 auto;
183
+ min-height: 0;
184
+ overflow-y: auto;
185
+ padding: 8px 12px;
186
+ /* The app's `code` stack (src/index.css). */
187
+ font-family:
188
+ source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
189
+ font-size: 10.5px;
190
+ line-height: 1.55;
191
+ color: var(--oh-text-dim);
192
+ user-select: text;
193
+ -webkit-user-select: text;
194
+ cursor: text;
195
+ /* Full lines, never truncated β€” long uvx/pip output wraps. */
196
+ white-space: pre-wrap;
197
+ overflow-wrap: anywhere;
198
+ }
199
+ .log:empty::before {
200
+ content: "Waiting for output…";
201
+ color: var(--oh-text-subtle);
202
+ }
203
+
204
+ .log-name {
205
+ color: var(--oh-text-subtle);
206
+ }
207
+ .log-line-error {
208
+ color: var(--oh-color-danger);
209
+ }
210
+
211
+ /* ── Failure state (main.mjs::showStartupFailure) ──────────────────── */
212
+ body.failed .spinner {
213
+ display: none;
214
+ }
215
+ body.failed .status {
216
+ color: var(--oh-color-danger);
217
+ /* The full error summary matters more than a tidy single line here. */
218
+ white-space: normal;
219
+ line-height: 1.45;
220
+ }
221
+ body.failed .hint {
222
+ display: none;
223
+ }
224
+ .quit-button {
225
+ display: none;
226
+ }
227
+ body.failed .quit-button {
228
+ display: inline-block;
229
+ }
230
+ </style>
231
+ </head>
232
+ <body>
233
+ <div class="container">
234
+ <img class="logo" src="build-resources/icon.png" alt="" draggable="false" />
235
+ <h1>OpenHands Agent Canvas</h1>
236
+ <p class="tagline">AI coding agent interface</p>
237
+ <svg class="spinner" viewBox="0 0 66 66" fill="none" aria-hidden="true"
238
+ xmlns="http://www.w3.org/2000/svg">
239
+ <path d="M63 33C63 16.4315 49.5685 3 33 3C16.4315 3 3 16.4315 3 33C3 49.5685 16.4315 63 33 63"
240
+ stroke="currentColor" stroke-width="6" stroke-linecap="round" />
241
+ </svg>
242
+ <p id="status" class="status">Starting services<span class="dots"></span></p>
243
+ <p class="hint">
244
+ First launch downloads Python + the OpenHands agent server.<br />
245
+ This can take a few minutes on a fresh machine.
246
+ </p>
247
+ <div class="actions">
248
+ <button id="details-toggle" type="button" class="ghost-button">
249
+ Show details
250
+ </button>
251
+ <button id="quit-button" type="button" class="ghost-button quit-button">
252
+ Quit
253
+ </button>
254
+ </div>
255
+ </div>
256
+ <div class="console" aria-label="Startup log">
257
+ <div class="console-head">
258
+ <span>Startup log</span>
259
+ <button id="copy-logs" type="button" class="ghost-button">Copy</button>
260
+ </div>
261
+ <div id="log" class="log"></div>
262
+ </div>
263
+ <script>
264
+ // Called from the Electron main process via webContents.executeJavaScript
265
+ // (see electron/main.mjs::setLoadingStatus). Replaces the entire status
266
+ // line, so the animated dots only appear when no concrete status is set.
267
+ window.__setLoadingStatus = function (line) {
268
+ var el = document.getElementById("status");
269
+ if (!el) return;
270
+ var text = String(line == null ? "" : line);
271
+ el.textContent = text || "";
272
+ };
273
+
274
+ // ── Startup-log console ──────────────────────────────────────────────
275
+ // Fed by the main process over IPC via preload.cjs (window.desktopBoot);
276
+ // undefined when the file is opened outside Electron, so every use is
277
+ // guarded and the static layout stays previewable in a plain browser.
278
+ // All DOM insertion uses textContent β€” log lines are untrusted process
279
+ // output and must never be parsed as HTML.
280
+ (function () {
281
+ var boot = window.desktopBoot;
282
+ var logEl = document.getElementById("log");
283
+ var statusEl = document.getElementById("status");
284
+ var toggleBtn = document.getElementById("details-toggle");
285
+ var copyBtn = document.getElementById("copy-logs");
286
+ var quitBtn = document.getElementById("quit-button");
287
+
288
+ // Cap DOM rows so an hours-long install can't grow layout cost;
289
+ // matches the main process's BOOT_LOG_MAX_LINES buffer cap.
290
+ var MAX_LINES = 2000;
291
+ var expanded = false;
292
+ // Follow the tail only while the user hasn't scrolled up.
293
+ var pinned = true;
294
+
295
+ logEl.addEventListener("scroll", function () {
296
+ pinned =
297
+ logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 8;
298
+ });
299
+
300
+ function appendBatch(batch) {
301
+ if (!batch || !batch.length) return;
302
+ var frag = document.createDocumentFragment();
303
+ for (var i = 0; i < batch.length; i++) {
304
+ var entry = batch[i];
305
+ var row = document.createElement("div");
306
+ row.className =
307
+ "log-line" + (entry.level === "error" ? " log-line-error" : "");
308
+ var name = document.createElement("span");
309
+ name.className = "log-name";
310
+ name.textContent = "[" + entry.name + "]";
311
+ row.appendChild(name);
312
+ row.appendChild(document.createTextNode(" " + entry.line));
313
+ frag.appendChild(row);
314
+ }
315
+ logEl.appendChild(frag);
316
+ while (logEl.childElementCount > MAX_LINES) {
317
+ logEl.removeChild(logEl.firstElementChild);
318
+ }
319
+ if (pinned) logEl.scrollTop = logEl.scrollHeight;
320
+ }
321
+
322
+ function setExpanded(next) {
323
+ expanded = next;
324
+ toggleBtn.textContent = expanded ? "Hide details" : "Show details";
325
+ if (boot) boot.setDetailsExpanded(expanded);
326
+ if (expanded && pinned) logEl.scrollTop = logEl.scrollHeight;
327
+ }
328
+
329
+ toggleBtn.addEventListener("click", function () {
330
+ setExpanded(!expanded);
331
+ });
332
+
333
+ copyBtn.addEventListener("click", function () {
334
+ if (!boot) return;
335
+ boot.copyLogs().then(function () {
336
+ copyBtn.textContent = "Copied";
337
+ setTimeout(function () {
338
+ copyBtn.textContent = "Copy";
339
+ }, 1200);
340
+ });
341
+ });
342
+
343
+ quitBtn.addEventListener("click", function () {
344
+ if (boot) boot.quit();
345
+ });
346
+
347
+ if (boot) {
348
+ boot.onLogBatch(appendBatch);
349
+ boot.onFatal(function (summary) {
350
+ document.body.classList.add("failed");
351
+ statusEl.textContent = String(summary == null ? "" : summary);
352
+ // Main already resized the window; sync the local toggle state.
353
+ if (!expanded) setExpanded(true);
354
+ });
355
+ }
356
+ })();
357
+ </script>
358
+ </body>
359
+ </html>
electron/main.mjs ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Electron Main Process β€” Agent Canvas Desktop
3
+ *
4
+ * Starts the full Agent Canvas stack (agent-server + automation via uvx,
5
+ * static frontend, ingress proxy), then opens a native BrowserWindow once
6
+ * the ingress is ready. Shows a loading screen while backends start.
7
+ *
8
+ * Path layout (electron-builder uses directories.app: 'electron'):
9
+ *
10
+ * Packaged (macOS example):
11
+ * Contents/Resources/app/ ← __dirname (main.mjs lives here)
12
+ * main.mjs
13
+ * loading.html
14
+ * scripts/ ← copied from repo scripts/
15
+ * config/ ← copied from repo config/
16
+ * build/ ← static frontend
17
+ * Contents/Resources/bin/ ← process.resourcesPath/bin
18
+ * uv uvx ← bundled via extraResources
19
+ *
20
+ * Dev (npm run desktop β†’ electron electron):
21
+ * electron/main.mjs ← __dirname = <repo>/electron/
22
+ * scripts/ config/ build/ ← one level up: <repo>/
23
+ * system uvx from PATH
24
+ *
25
+ * When packaged, scripts/config/build are siblings of main.mjs so
26
+ * projectRoot === __dirname. In dev they are one level up.
27
+ *
28
+ * The dev command points electron at the electron/ DIRECTORY, not at
29
+ * main.mjs directly. Electron's default_app only reads name/productName/
30
+ * version out of <arg>/package.json, so passing the file makes it look for
31
+ * electron/main.mjs/package.json, miss, and leave app.name at the host
32
+ * bundle's default β€” "Electron" in the menu bar and userData path.
33
+ */
34
+
35
+ import {
36
+ app,
37
+ BrowserWindow,
38
+ clipboard,
39
+ dialog,
40
+ ipcMain,
41
+ nativeImage,
42
+ nativeTheme,
43
+ shell,
44
+ } from "electron";
45
+ import { chmodSync, existsSync } from "node:fs";
46
+ import { dirname, join } from "node:path";
47
+ import { fileURLToPath, pathToFileURL } from "node:url";
48
+ import { spawnSync } from "node:child_process";
49
+
50
+ import { isExternalBrowsableUrl, isLoopbackAppUrl } from "./lib/window-url-policy.mjs";
51
+
52
+ const __filename = fileURLToPath(import.meta.url);
53
+ const __dirname = dirname(__filename);
54
+
55
+ // ── Path resolution ───────────────────────────────────────────────────────────
56
+ // Packaged (directories.app: 'electron'): scripts/config/build are SIBLINGS of
57
+ // main.mjs inside Resources/app/, so projectRoot === __dirname.
58
+ // Dev (electron electron): those directories are one level UP in the
59
+ // repo root, so projectRoot === join(__dirname, '..').
60
+ // Both branches key off __dirname (always <repo>/electron in dev), not
61
+ // app.getAppPath(), so the entry-point form doesn't affect them.
62
+
63
+ const projectRoot = app.isPackaged ? __dirname : join(__dirname, "..");
64
+ const buildDir = join(projectRoot, "build");
65
+ const scriptsDir = join(projectRoot, "scripts");
66
+
67
+ // OpenHands raised-hands app icon, used as the BrowserWindow.icon option.
68
+ // Windows gets the multi-size icon.ico (16β†’256, small sizes as classic BMP
69
+ // entries β€” the Windows shell needs those); Linux uses the 1024Γ—1024 PNG
70
+ // for its taskbar. On macOS the dock icon comes from the .app bundle's
71
+ // icon.icns, so this path is unused there. Both files live next to main.mjs
72
+ // in dev and are copied into Resources/app/build-resources/ via the
73
+ // `files:` array. Regenerate with `npm run generate-icons`.
74
+ const appIconPath = join(
75
+ __dirname,
76
+ "build-resources",
77
+ process.platform === "win32" ? "icon.ico" : "icon.png",
78
+ );
79
+
80
+ // electron-builder's NSIS shortcuts are stamped with AppUserModelId
81
+ // ${APP_ID} (WinShell::SetLnkAUMI in installer.nsh). Declare the same id so
82
+ // running/pinned taskbar entries group with the shortcut and inherit its
83
+ // icon. Must match appId in electron-builder.config.mjs, and must be set
84
+ // before any BrowserWindow is created.
85
+ if (process.platform === "win32") {
86
+ app.setAppUserModelId("dev.openhands.agent-canvas");
87
+ }
88
+
89
+ // ── Bundled uv ────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Inject the bundled uv binary into PATH so that uvx calls inside
93
+ * dev-with-automation.mjs resolve to our bundled binary.
94
+ * No-op in dev mode (falls back to system uv).
95
+ */
96
+ function injectBundledUv() {
97
+ if (!app.isPackaged) return;
98
+
99
+ const isWin = process.platform === "win32";
100
+ const uvName = isWin ? "uv.exe" : "uv";
101
+ const uvxName = isWin ? "uvx.exe" : "uvx";
102
+ const binDir = join(process.resourcesPath, "bin");
103
+ const uvPath = join(binDir, uvName);
104
+
105
+ // We only probe for `uv` here β€” `uv` and `uvx` ship together in the
106
+ // bundle (`download-uv.mjs` writes both), so if `uv` is present we
107
+ // assume `uvx` is too. `uvxAvailable()` is called separately by
108
+ // start-up code to confirm the resolved binary actually runs.
109
+ if (!existsSync(uvPath)) {
110
+ console.warn("[desktop] Bundled uv not found at", uvPath);
111
+ return;
112
+ }
113
+
114
+ // electron-builder copies files without preserving the +x bit on Unix.
115
+ if (!isWin) {
116
+ try {
117
+ chmodSync(uvPath, 0o755);
118
+ const uvxPath = join(binDir, uvxName);
119
+ if (existsSync(uvxPath)) chmodSync(uvxPath, 0o755);
120
+ } catch {}
121
+ }
122
+
123
+ const sep = isWin ? ";" : ":";
124
+ process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
125
+ console.log("[desktop] Injected bundled uv from", binDir);
126
+ }
127
+
128
+ /**
129
+ * Verify uvx is reachable (either bundled or system).
130
+ * Returns true/false β€” callers show a dialog on false.
131
+ */
132
+ function uvxAvailable() {
133
+ const cmd = process.platform === "win32" ? "uvx.exe" : "uvx";
134
+ const r = spawnSync(cmd, ["--version"], { stdio: "pipe" });
135
+ return r.status === 0;
136
+ }
137
+
138
+ /**
139
+ * Inject the bundled Node.js distribution into PATH so subsequent spawns
140
+ * can find `node`, `npm`, and `npx`.
141
+ *
142
+ * When the app runs as a packaged .app on macOS, the system PATH is minimal
143
+ * (/usr/bin:/bin only) β€” Homebrew, nvm, asdf etc. installs of Node are
144
+ * invisible. Two breakages flow from that:
145
+ *
146
+ * 1. The dev-with-automation.mjs stack spawns `node scripts/ingress.mjs`
147
+ * and `node scripts/static-server.mjs`; if `node` is not found those
148
+ * processes fail silently and port 8000 never responds.
149
+ * 2. Most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.)
150
+ * use `command: "npx"`. When the agent-server tries to spawn one the
151
+ * missing `npx` makes the spawn fail with ENOENT; the SDK reports it
152
+ * as an `error_kind: "connection"` MCP test failure, surfaced in the
153
+ * install modal as "Could not reach the server".
154
+ *
155
+ * We tried bridging via Electron-as-Node (ELECTRON_RUN_AS_NODE=1) wrappers
156
+ * first. That fixed the ENOENT but introduced a new failure: stdio MCP
157
+ * servers spawned through the wrapper exited with "McpError: Connection
158
+ * closed" before the JSON-RPC handshake completed. Electron-as-Node is
159
+ * fine for our networking helper scripts but its stdin/stdout semantics
160
+ * differ enough from a vanilla `node` binary that stdio JSON-RPC servers
161
+ * are not reliable under it. The robust fix is to ship a real Node.js
162
+ * runtime as an extraResource (see scripts/download-node.mjs and the
163
+ * `resources/node/` entry in electron-builder.config.mjs) and just put
164
+ * its bin dir on PATH.
165
+ *
166
+ * No-op in dev mode (`npm run desktop`): the user's terminal PATH already
167
+ * has Node tooling and `app.isPackaged` is false. If the bundled dir is
168
+ * somehow missing (e.g. the download step was skipped during packaging),
169
+ * we log a loud warning and leave PATH untouched so the failure mode is
170
+ * obvious in the console rather than confusing downstream.
171
+ */
172
+ function injectBundledNode() {
173
+ if (!app.isPackaged) return;
174
+
175
+ const isWin = process.platform === "win32";
176
+ const nodeRoot = join(process.resourcesPath, "node");
177
+ // POSIX Node distributions put binaries in bin/; Windows zips put node.exe
178
+ // and the npm.cmd / npx.cmd wrappers at the distribution root.
179
+ const binDir = isWin ? nodeRoot : join(nodeRoot, "bin");
180
+ const nodeExe = isWin ? join(nodeRoot, "node.exe") : join(binDir, "node");
181
+
182
+ if (!existsSync(nodeExe)) {
183
+ console.warn(
184
+ `[desktop] Bundled Node.js not found at ${nodeExe} β€” backend ` +
185
+ "scripts and stdio MCP servers will fail. Run `npm run download-node` " +
186
+ "and rebuild.",
187
+ );
188
+ return;
189
+ }
190
+
191
+ // node.exe alone is not enough. npm / npx are wrapper scripts that exec
192
+ // npm's JS entry points out of the distribution's own node_modules, and
193
+ // that directory is the one piece electron-builder drops on Windows (see
194
+ // restoreBundledNodeNpm in electron-builder.config.mjs). Since we PREPEND
195
+ // this dir to PATH, a half-copied bundle doesn't just fail to help β€” it
196
+ // shadows the user's working npm with shims that die on MODULE_NOT_FOUND.
197
+ // Warn loudly, but still inject: `node` itself works and the backend
198
+ // launcher scripts need it.
199
+ const npmCli = isWin
200
+ ? join(nodeRoot, "node_modules", "npm", "bin", "npm-cli.js")
201
+ : join(nodeRoot, "lib", "node_modules", "npm", "bin", "npm-cli.js");
202
+ if (!existsSync(npmCli)) {
203
+ console.warn(
204
+ `[desktop] Bundled npm is incomplete β€” ${npmCli} is missing. ` +
205
+ "`npx`-launched subprocesses (stdio MCP servers, ACP servers) will " +
206
+ "fail with MODULE_NOT_FOUND, and this bundle shadows any npm already " +
207
+ "on PATH. Rebuild with `npm run download-node`.",
208
+ );
209
+ }
210
+
211
+ // electron-builder doesn't always preserve the +x bit on POSIX. node, npm,
212
+ // and npx need to be executable for shell PATH lookup to consider them.
213
+ if (!isWin) {
214
+ const required = ["node", "npm", "npx"];
215
+ for (const name of required) {
216
+ const p = join(binDir, name);
217
+ try {
218
+ if (existsSync(p)) chmodSync(p, 0o755);
219
+ } catch {
220
+ // best-effort: a stale read-only mount or test fixture is fine to skip
221
+ }
222
+ }
223
+ }
224
+
225
+ const sep = isWin ? ";" : ":";
226
+ process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`;
227
+ console.log("[desktop] Injected bundled Node from", binDir);
228
+ }
229
+
230
+ // ── Readiness polling ���────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Wait until `url` responds at all (status < 500). Used to confirm the
234
+ * ingress proxy is bound β€” not a guarantee that the agent-server behind it
235
+ * is ready. Use {@link waitForAgentServer} for that.
236
+ */
237
+ async function waitForUrl(url, timeoutMs = 120_000, intervalMs = 600) {
238
+ const deadline = Date.now() + timeoutMs;
239
+ while (Date.now() < deadline) {
240
+ try {
241
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
242
+ if (res.status < 500) return;
243
+ } catch {}
244
+ await new Promise((r) => setTimeout(r, intervalMs));
245
+ }
246
+ throw new Error(
247
+ `Timed out waiting for ${url} to become ready (${timeoutMs / 1000}s).`,
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Wait until `url` returns HTTP 200 β€” meaning the agent-server itself is
253
+ * serving requests, not just that the ingress proxy is up.
254
+ *
255
+ * On first launch, `uvx` has to download a Python toolchain and install
256
+ * `openhands-agent-server` and its workspace deps from PyPI, which can
257
+ * easily take a few minutes on a slow network. We poll the route end-to-end
258
+ * (through ingress on port 8000, so a missing or restarted ingress is also
259
+ * caught) instead of just probing the static-server fallback that
260
+ * `waitForUrl` would accept.
261
+ */
262
+ async function waitForAgentServer(
263
+ url = "http://localhost:8000/server_info",
264
+ timeoutMs = 10 * 60_000,
265
+ intervalMs = 1_000,
266
+ ) {
267
+ const deadline = Date.now() + timeoutMs;
268
+ while (Date.now() < deadline) {
269
+ try {
270
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
271
+ // Only 200 is success here. 502 from ingress means the upstream agent
272
+ // server isn't bound yet; 401 means auth is required and the bundled
273
+ // key didn't reach us β€” we still treat that as "the agent server is
274
+ // up", because the proxy got a real HTTP response from it.
275
+ if (res.status === 200 || res.status === 401) return;
276
+ } catch {
277
+ // Transient network / DNS / timeout β€” keep polling until the deadline.
278
+ }
279
+ await new Promise((r) => setTimeout(r, intervalMs));
280
+ }
281
+ throw new Error(
282
+ `Agent server at ${url} never came up (${Math.round(timeoutMs / 1000)}s). ` +
283
+ "Check the terminal log for errors from uvx / the agent-server process.",
284
+ );
285
+ }
286
+
287
+ // ── Windows ───────────────────────────────────────────────────────────────────
288
+
289
+ let loadingWin = null;
290
+ let mainWin = null;
291
+
292
+ // Collapsed splash size β€” loading.html's .container height must match. The
293
+ // expanded height reveals the startup-log console below it ("Show details").
294
+ const LOADING_WIN_WIDTH = 460;
295
+ const LOADING_WIN_HEIGHT = 360;
296
+ const LOADING_WIN_EXPANDED_HEIGHT = 560;
297
+
298
+ /**
299
+ * Grow or shrink the loading window to reveal/hide the startup-log console.
300
+ * Keeps the top edge fixed so the splash content doesn't jump. Invoked from
301
+ * the renderer ("Show details" toggle) and from showStartupFailure().
302
+ */
303
+ function setLoadingWindowExpanded(expanded) {
304
+ if (!loadingWin || loadingWin.isDestroyed()) return;
305
+ const bounds = loadingWin.getBounds();
306
+ const height = expanded ? LOADING_WIN_EXPANDED_HEIGHT : LOADING_WIN_HEIGHT;
307
+ if (bounds.height === height) return;
308
+ // macOS ignores programmatic resizes of resizable:false windows on some
309
+ // Electron versions β€” lift the flag around the change.
310
+ loadingWin.setResizable(true);
311
+ loadingWin.setBounds({ ...bounds, height }, true);
312
+ loadingWin.setResizable(false);
313
+ }
314
+
315
+ function createLoadingWindow() {
316
+ loadingWin = new BrowserWindow({
317
+ width: LOADING_WIN_WIDTH,
318
+ // Tall enough to fit the streaming status line + the "first launch can
319
+ // take a few minutes" hint without scrollbars.
320
+ height: LOADING_WIN_HEIGHT,
321
+ resizable: false,
322
+ frame: false,
323
+ center: true,
324
+ show: false,
325
+ // Pre-paint window color; must match --oh-background in loading.html.
326
+ backgroundColor: "#0b0e14",
327
+ icon: appIconPath,
328
+ webPreferences: {
329
+ nodeIntegration: false,
330
+ contextIsolation: true,
331
+ // Bridges the startup-log console over IPC (see preload.cjs).
332
+ preload: join(__dirname, "preload.cjs"),
333
+ },
334
+ });
335
+
336
+ // The renderer can only receive IPC once the page has loaded β€” replay the
337
+ // lines buffered until now, then stream live batches (see appendBootLog).
338
+ loadingWin.webContents.on("did-finish-load", () => {
339
+ if (!loadingWin || loadingWin.isDestroyed()) return;
340
+ clearTimeout(bootLogFlushTimer);
341
+ bootLogFlushTimer = null;
342
+ bootLogPending = [];
343
+ if (bootLog.length) {
344
+ loadingWin.webContents.send("boot-log:batch", bootLog.slice());
345
+ }
346
+ bootLogReady = true;
347
+ if (fatalSummary) {
348
+ loadingWin.webContents.send("boot-log:fatal", fatalSummary);
349
+ }
350
+ });
351
+
352
+ loadingWin.loadFile(join(__dirname, "loading.html"));
353
+ loadingWin.once("ready-to-show", () => loadingWin?.show());
354
+ }
355
+
356
+ function createMainWindow() {
357
+ mainWin = new BrowserWindow({
358
+ width: 1440,
359
+ height: 900,
360
+ minWidth: 800,
361
+ minHeight: 600,
362
+ show: false,
363
+ // App-shell background (--oh-background in src/index.css) β€” avoids white
364
+ // flashes during the show β†’ maximize repaint after the splash closes.
365
+ backgroundColor: "#0b0e14",
366
+ titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
367
+ icon: appIconPath,
368
+ webPreferences: {
369
+ nodeIntegration: false,
370
+ contextIsolation: true,
371
+ },
372
+ });
373
+
374
+ mainWin.loadURL("http://localhost:8000");
375
+
376
+ mainWin.once("ready-to-show", () => {
377
+ loadingWin?.destroy();
378
+ loadingWin = null;
379
+ mainWin?.show();
380
+ mainWin?.maximize();
381
+ });
382
+
383
+ // Route window.open() calls appropriately.
384
+ mainWin.webContents.setWindowOpenHandler(({ url }) => {
385
+ // The "Login with OpenHands Cloud" device-flow opens about:blank immediately
386
+ // on the user's click (to beat popup blockers), then navigates the popup to
387
+ // the OAuth verification URL once it has one. We must allow about:blank
388
+ // through so window.open() returns a non-null WindowProxy; the did-create-window
389
+ // handler below redirects the popup to the system browser when it navigates.
390
+ if (url === "about:blank") {
391
+ return {
392
+ action: "allow",
393
+ overrideBrowserWindowOptions: { width: 800, height: 700 },
394
+ };
395
+ }
396
+ // All other URLs open directly in the system browser. The loopback test
397
+ // goes through URL parsing: prefix matching would also accept
398
+ // attacker-controlled hosts like http://localhost.evil.com (or
399
+ // http://localhost@evil.com) and render them in a chromeless native
400
+ // window. Schemes outside the openExternal allowlist are denied
401
+ // outright β€” shell.openExternal would forward them to OS protocol
402
+ // handlers.
403
+ if (isLoopbackAppUrl(url)) {
404
+ return { action: "allow" };
405
+ }
406
+ if (isExternalBrowsableUrl(url)) {
407
+ shell.openExternal(url);
408
+ }
409
+ return { action: "deny" };
410
+ });
411
+
412
+ // When the renderer opens a popup (the about:blank above), watch for its
413
+ // first navigation away from about:blank. That navigation will be to the
414
+ // OAuth verification URL β€” open it in the system browser and close the
415
+ // now-unneeded Electron popup.
416
+ mainWin.webContents.on("did-create-window", (popupWin) => {
417
+ popupWin.webContents.on("will-navigate", (_event, url) => {
418
+ if (url !== "about:blank" && !isLoopbackAppUrl(url)) {
419
+ _event.preventDefault();
420
+ if (isExternalBrowsableUrl(url)) {
421
+ shell.openExternal(url);
422
+ }
423
+ popupWin.close();
424
+ }
425
+ });
426
+ });
427
+
428
+ mainWin.on("closed", () => {
429
+ mainWin = null;
430
+ });
431
+ }
432
+
433
+ // ── Startup log buffer ────────────────────────────────────────────────────────
434
+ //
435
+ // Every service log line (all services, all levels, sanitized) is kept in a
436
+ // bounded buffer and streamed to the loading window's console in batches over
437
+ // IPC (see preload.cjs + loading.html). The buffer is the single source of
438
+ // truth: it is replayed once the page loads (lines emitted earlier would
439
+ // otherwise be lost) and it backs the "Copy logs" action. In a packaged app
440
+ // this console is the only log surface β€” stdout/stderr go to /dev/null when
441
+ // launched from Finder, and the winston file logger is a no-op there (see
442
+ // AGENTS.md on the node_modules strip).
443
+
444
+ const BOOT_LOG_MAX_LINES = 2000;
445
+ const BOOT_LOG_FLUSH_MS = 200;
446
+
447
+ const bootLog = []; // {name, line, level}[] β€” level: stdout|stderr|info|warn|error
448
+ let bootLogPending = [];
449
+ let bootLogFlushTimer = null;
450
+ let bootLogReady = false; // true once loading.html has loaded and can receive
451
+ let fatalSummary = null;
452
+
453
+ // SGR color codes AND cursor-control CSI sequences (uv/uvicorn can emit
454
+ // either when they mis-detect a TTY).
455
+ const ANSI_CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
456
+
457
+ /**
458
+ * Strip ANSI escapes and reduce carriage-return progress redraws (e.g. uv
459
+ * download bars arrive as one chunk of "\r"-separated frames) to the final
460
+ * frame β€” what a real terminal would have settled on.
461
+ */
462
+ function sanitizeLogLine(line) {
463
+ const frames = String(line ?? "")
464
+ .replace(ANSI_CSI_RE, "")
465
+ .split("\r")
466
+ .map((s) => s.trim())
467
+ .filter(Boolean);
468
+ return frames.length ? frames[frames.length - 1] : "";
469
+ }
470
+
471
+ function appendBootLog(name, line, level) {
472
+ const entry = { name, line, level };
473
+ bootLog.push(entry);
474
+ if (bootLog.length > BOOT_LOG_MAX_LINES) {
475
+ bootLog.splice(0, bootLog.length - BOOT_LOG_MAX_LINES);
476
+ }
477
+ bootLogPending.push(entry);
478
+ if (!bootLogFlushTimer) {
479
+ bootLogFlushTimer = setTimeout(flushBootLog, BOOT_LOG_FLUSH_MS);
480
+ }
481
+ }
482
+
483
+ function flushBootLog() {
484
+ clearTimeout(bootLogFlushTimer);
485
+ bootLogFlushTimer = null;
486
+ if (!bootLogPending.length) return;
487
+ const batch = bootLogPending;
488
+ bootLogPending = [];
489
+ // Not ready / window gone: drop the batch β€” the entries stay in bootLog,
490
+ // which did-finish-load replays wholesale.
491
+ if (bootLogReady && loadingWin && !loadingWin.isDestroyed()) {
492
+ loadingWin.webContents.send("boot-log:batch", batch);
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Switch the splash into its failure state: expand the console and show the
498
+ * error summary with Copy logs / Quit actions, keeping the window open so the
499
+ * user can actually read why startup failed. Returns false when the loading
500
+ * window is gone (caller falls back to a native dialog).
501
+ */
502
+ function showStartupFailure(summary) {
503
+ if (!loadingWin || loadingWin.isDestroyed()) return false;
504
+ fatalSummary = summary;
505
+ setLoadingWindowExpanded(true);
506
+ if (bootLogReady) {
507
+ flushBootLog();
508
+ loadingWin.webContents.send("boot-log:fatal", summary);
509
+ }
510
+ // If the page hasn't loaded yet, did-finish-load replays the buffer and
511
+ // then delivers fatalSummary.
512
+ return true;
513
+ }
514
+
515
+ // IPC surface for the loading window (see preload.cjs). Guarded to that
516
+ // window's webContents so the main app window can never reach these.
517
+ function isLoadingWinEvent(event) {
518
+ return (
519
+ loadingWin !== null &&
520
+ !loadingWin.isDestroyed() &&
521
+ event.sender === loadingWin.webContents
522
+ );
523
+ }
524
+
525
+ ipcMain.handle("boot-log:set-expanded", (event, expanded) => {
526
+ if (!isLoadingWinEvent(event)) return;
527
+ setLoadingWindowExpanded(Boolean(expanded));
528
+ });
529
+
530
+ ipcMain.handle("boot-log:copy", (event) => {
531
+ if (!isLoadingWinEvent(event)) return 0;
532
+ clipboard.writeText(bootLog.map((e) => `[${e.name}] ${e.line}`).join("\n"));
533
+ return bootLog.length;
534
+ });
535
+
536
+ // The frameless splash has no close control; the failure state shows a Quit
537
+ // button instead.
538
+ ipcMain.handle("boot-log:quit", (event) => {
539
+ if (!isLoadingWinEvent(event)) return;
540
+ app.quit();
541
+ });
542
+
543
+ // ── Backend stack ─────────────────────────────────────────────────────────────
544
+
545
+ /**
546
+ * Update the status line on the loading window, if it's still alive.
547
+ *
548
+ * The loading screen exposes a global `window.__setLoadingStatus(line)`
549
+ * function (see loading.html) that swaps the status text. We call it via
550
+ * `executeJavaScript` so no preload script / IPC plumbing is needed.
551
+ *
552
+ * Best-effort: any failure (window destroyed, JS not loaded yet, etc.) is
553
+ * swallowed β€” this is purely a UX nicety and must never crash the launcher.
554
+ */
555
+ function setLoadingStatus(line) {
556
+ if (!loadingWin || loadingWin.isDestroyed()) return;
557
+ // Limit to a single line, max ~120 chars, to keep the splash readable.
558
+ const oneLine = String(line ?? "")
559
+ .replace(/\s+/g, " ")
560
+ .trim()
561
+ .slice(0, 120);
562
+ if (!oneLine) return;
563
+ const safe = JSON.stringify(oneLine);
564
+ loadingWin.webContents
565
+ .executeJavaScript(
566
+ `window.__setLoadingStatus && window.__setLoadingStatus(${safe});`,
567
+ true,
568
+ )
569
+ .catch(() => {});
570
+ }
571
+
572
+ /**
573
+ * Phase marker: headline + a line in the startup-log console, so the log
574
+ * records which stage a failed boot died in.
575
+ */
576
+ function setBootPhase(message) {
577
+ appendBootLog("desktop", message, "info");
578
+ setLoadingStatus(message);
579
+ }
580
+
581
+ /**
582
+ * Last few `level: "error"` service log lines (spawn failures, non-zero
583
+ * exits). Appended to the startup-failure dialog: a packaged app launched
584
+ * from Finder has stdout/stderr wired to /dev/null, so without this a
585
+ * crashed ingress/static-server surfaces only as an opaque "timed out
586
+ * waiting for http://localhost:8000" message.
587
+ */
588
+ const recentServiceErrors = [];
589
+
590
+ /**
591
+ * Forward dev-stack service log lines to (a) the loading screen and (b) the
592
+ * terminal log. The terminal already receives them via `logService`; we add
593
+ * a tee here so the user can see what's happening on first launch when uvx
594
+ * is downloading Python + agent-server.
595
+ */
596
+ function handleServiceLog(name, line, level) {
597
+ if (!line) return;
598
+ const clean = sanitizeLogLine(line);
599
+ if (!clean) return;
600
+ // Full-fidelity stream: every service and level goes to the console buffer.
601
+ // The one-line headline below stays filtered to the interesting services.
602
+ appendBootLog(name, clean, level);
603
+ if (name === "agent-server" || name === "automation") {
604
+ setLoadingStatus(`${name}: ${clean}`);
605
+ }
606
+ // Mirror errors to a `[desktop]` terminal line so dev runs stay grep-friendly.
607
+ if (level === "error") {
608
+ console.error(`[desktop] [${name}] ${clean}`);
609
+ // Errors from ANY service (including ingress/static, which the headline
610
+ // filter above skips) are worth showing β€” a dead ingress is exactly the
611
+ // case where the user would otherwise stare at a silent 120 s timeout.
612
+ setLoadingStatus(`${name}: ${clean}`);
613
+ recentServiceErrors.push(`${name}: ${clean}`);
614
+ if (recentServiceErrors.length > 5) recentServiceErrors.shift();
615
+ }
616
+ }
617
+
618
+ async function startStack() {
619
+ const entryUrl = pathToFileURL(
620
+ join(scriptsDir, "dev-with-automation.mjs"),
621
+ ).href;
622
+ const { main } = await import(entryUrl);
623
+
624
+ // main() starts agent-server + automation backend + static server + ingress.
625
+ // skipNpmCheck: npm is not needed at runtime in static mode.
626
+ // agentServerReadyTimeoutMs: dev defaults to 60 s (warm uvx cache); a
627
+ // packaged binary on a fresh machine can spend several minutes inside
628
+ // uvx the first time, downloading Python + installing openhands-
629
+ // agent-server from PyPI. 10 minutes is generous but bounded.
630
+ // onServiceLog: stream uvx/agent-server output to the loading window so
631
+ // the user sees progress instead of an indefinite spinner.
632
+ const result = await main({
633
+ bannerTitle: "OpenHands Agent Canvas",
634
+ staticMode: true,
635
+ staticDir: buildDir,
636
+ mode: "agent-canvas",
637
+ isPublic: false,
638
+ skipNpmCheck: true,
639
+ agentServerReadyTimeoutMs: 10 * 60_000,
640
+ onServiceLog: handleServiceLog,
641
+ });
642
+
643
+ // main() returns { config, agentServerReady } β€” treat a timeout as a fatal
644
+ // startup error so the splash shows a clear dialog instead of dropping the
645
+ // user into a half-booted UI that will only emit "Request timeout" popups.
646
+ if (result?.agentServerReady === false) {
647
+ throw new Error(
648
+ "The agent server did not finish starting in time. " +
649
+ "On first launch this can take several minutes while uvx downloads " +
650
+ "Python and the OpenHands agent-server from PyPI. " +
651
+ "Check your internet connection and try again.",
652
+ );
653
+ }
654
+ }
655
+
656
+ // ── App lifecycle ─────────────────────────────────────────────────────────────
657
+
658
+ app.whenReady().then(async () => {
659
+ nativeTheme.themeSource = "dark";
660
+
661
+ // Set the dock icon explicitly on macOS so `npm run desktop` shows the
662
+ // OpenHands logo instead of the default Electron logo. In a packaged
663
+ // build the .app bundle's icon.icns already provides this, but
664
+ // app.dock.setIcon() is a cheap idempotent override that also fixes
665
+ // the dev workflow.
666
+ if (process.platform === "darwin" && app.dock && existsSync(appIconPath)) {
667
+ app.dock.setIcon(nativeImage.createFromPath(appIconPath));
668
+ }
669
+
670
+ injectBundledUv();
671
+ injectBundledNode();
672
+
673
+ if (!uvxAvailable()) {
674
+ dialog.showErrorBox(
675
+ "Missing prerequisite: uv",
676
+ app.isPackaged
677
+ ? "The bundled uv binary could not be found. Please reinstall OpenHands Agent Canvas."
678
+ : "uv (uvx) is not installed.\n\nInstall it from https://docs.astral.sh/uv/ then restart.",
679
+ );
680
+ app.quit();
681
+ return;
682
+ }
683
+
684
+ createLoadingWindow();
685
+
686
+ try {
687
+ setBootPhase("Starting backend services…");
688
+ await startStack();
689
+
690
+ // Stage 1: ingress proxy is bound (anything < 500 on /).
691
+ setBootPhase("Waiting for proxy…");
692
+ await waitForUrl("http://localhost:8000");
693
+
694
+ // Stage 2: the agent-server behind the proxy is actually serving
695
+ // requests. `startStack()` already waited for this internally, but we
696
+ // re-probe end-to-end here so that if the user closes the splash race
697
+ // window between processes binding, we still open the main window with
698
+ // a live backend. Cheap (a single 200 response) when everything is up.
699
+ setBootPhase("Connecting to agent server…");
700
+ await waitForAgentServer("http://localhost:8000/server_info", 60_000);
701
+
702
+ setBootPhase("Ready.");
703
+ createMainWindow();
704
+ } catch (err) {
705
+ const summary =
706
+ err.message +
707
+ " Ensure ports 8000, 18000, and 18001 are free, then try again.";
708
+ // Record the failure in the terminal and the startup-log buffer so it
709
+ // shows (and copies) as the final console line.
710
+ console.error("[desktop] Startup failed:", err);
711
+ appendBootLog("desktop", summary, "error");
712
+ // Keep the splash open in its failure state so the full startup log can
713
+ // be read and copied; the app quits via the splash's Quit button (or
714
+ // Cmd+Q / closing the window).
715
+ if (showStartupFailure(summary)) return;
716
+ // Loading window already gone β€” fall back to the old dialog-and-quit.
717
+ const errorTail = recentServiceErrors.length
718
+ ? `\n\nRecent service errors:\n${recentServiceErrors.join("\n")}`
719
+ : "";
720
+ dialog.showErrorBox("OpenHands Agent Canvas failed to start", summary + errorTail);
721
+ app.quit();
722
+ }
723
+ });
724
+
725
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
726
+ //
727
+ // dev-with-automation.mjs spawns the backend processes with detached:true so
728
+ // they form their own OS process groups and survive the parent's death by
729
+ // default. We must explicitly kill them when the app quits.
730
+ //
731
+ // createShutdownHookRegistry (dev-process-utils.mjs) already registered a
732
+ // SIGTERM handler that iterates every tracked process, calls signalProcessTree
733
+ // on its group, waits for exit, then calls process.exit(0). We just need to
734
+ // fire that handler before Electron lets the process die.
735
+ //
736
+ // Flow:
737
+ // user closes window / Cmd+Q
738
+ // β†’ window-all-closed β†’ app.quit()
739
+ // β†’ before-quit fires (first time) β†’ we preventDefault + send SIGTERM
740
+ // β†’ SIGTERM handler kills all children, calls process.exit(0)
741
+ // β†’ before-quit fires again (cleanupStarted=true) β†’ we return, Electron exits
742
+ //
743
+ // Windows has no real POSIX signals: process.kill(pid, "SIGTERM") would
744
+ // terminate this process WITHOUT running the "SIGTERM" listener, skipping
745
+ // cleanup and orphaning the children on ports 8000/18000/18001 (the next
746
+ // launch then fails at startup). process.emit("SIGTERM") runs the same
747
+ // registered handler in-process instead.
748
+
749
+ let cleanupStarted = false;
750
+
751
+ app.on("before-quit", (event) => {
752
+ if (cleanupStarted) return; // SIGTERM cleanup already running β€” allow exit
753
+
754
+ cleanupStarted = true;
755
+ event.preventDefault();
756
+
757
+ console.log("[desktop] Stopping backend services…");
758
+ if (process.platform === "win32") {
759
+ // Run the cleanup handler in-process (see header note). emit() returns
760
+ // false when no listener is registered β€” the stack never started, so
761
+ // there is nothing to clean up and we can exit immediately.
762
+ if (!process.emit("SIGTERM")) app.exit(0);
763
+ } else {
764
+ process.kill(process.pid, "SIGTERM");
765
+ }
766
+
767
+ // Safety net: if the SIGTERM handler doesn't finish within 6 s, force-quit.
768
+ const t = setTimeout(() => {
769
+ console.warn("[desktop] Cleanup timed out β€” forcing exit");
770
+ app.exit(0);
771
+ }, 6000);
772
+ if (t.unref) t.unref();
773
+ });
774
+
775
+ app.on("window-all-closed", () => {
776
+ app.quit();
777
+ });
778
+
779
+ // macOS: clicking the dock icon when no window is open re-launches the app.
780
+ app.on("activate", () => {
781
+ if (BrowserWindow.getAllWindows().length === 0) {
782
+ // The backend is already running β€” just open a new renderer window.
783
+ if (mainWin === null) createMainWindow();
784
+ }
785
+ });
electron/package.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "agent-canvas",
3
+ "productName": "OpenHands Agent Canvas",
4
+ "version": "1.0.0",
5
+ "private": true,
6
+ "main": "main.mjs"
7
+ }
electron/preload.cjs ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Preload for the loading window (loading.html).
3
+ *
4
+ * Bridges the startup-log console to the main process over IPC while keeping
5
+ * contextIsolation (and the default renderer sandbox) intact. The one-line
6
+ * status headline intentionally does NOT go through here β€” main.mjs sets it
7
+ * via executeJavaScript β†’ window.__setLoadingStatus (see setLoadingStatus).
8
+ *
9
+ * CommonJS on purpose: sandboxed preload scripts cannot use ESM.
10
+ */
11
+ const { contextBridge, ipcRenderer } = require("electron");
12
+
13
+ contextBridge.exposeInMainWorld("desktopBoot", {
14
+ /** Subscribe to batched startup-log lines: cb([{name, line, level}, …]). */
15
+ onLogBatch(cb) {
16
+ if (typeof cb !== "function") return;
17
+ ipcRenderer.on("boot-log:batch", (_event, batch) => cb(batch));
18
+ },
19
+ /** Subscribe to the fatal startup-failure notification: cb(summary). */
20
+ onFatal(cb) {
21
+ if (typeof cb !== "function") return;
22
+ ipcRenderer.on("boot-log:fatal", (_event, summary) => cb(summary));
23
+ },
24
+ /** Grow/shrink the window to reveal or hide the console panel. */
25
+ setDetailsExpanded: (expanded) =>
26
+ ipcRenderer.invoke("boot-log:set-expanded", Boolean(expanded)),
27
+ /** Copy the full buffered startup log to the clipboard. */
28
+ copyLogs: () => ipcRenderer.invoke("boot-log:copy"),
29
+ /** Quit the app (failure-state action; the frameless splash has no close UI). */
30
+ quit: () => ipcRenderer.invoke("boot-log:quit"),
31
+ });
public/android-chrome-192x192.png ADDED
public/android-chrome-512x512.png ADDED
public/apple-touch-icon.png ADDED
public/browserconfig.xml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <browserconfig>
3
+ <msapplication>
4
+ <tile>
5
+ <square150x150logo src="/mstile-150x150.png"/>
6
+ <TileColor>#da532c</TileColor>
7
+ </tile>
8
+ </msapplication>
9
+ </browserconfig>
public/favicon-16x16.png ADDED
public/favicon-32x32.png ADDED
public/favicon.ico ADDED
public/favicon.svg ADDED
public/mockServiceWorker.js ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable */
2
+ /* tslint:disable */
3
+
4
+ /**
5
+ * Mock Service Worker.
6
+ * @see https://github.com/mswjs/msw
7
+ * - Please do NOT modify this file.
8
+ */
9
+
10
+ const PACKAGE_VERSION = '2.15.0'
11
+ const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
12
+ const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13
+ const activeClientIds = new Set()
14
+
15
+ addEventListener('install', function () {
16
+ self.skipWaiting()
17
+ })
18
+
19
+ addEventListener('activate', function (event) {
20
+ event.waitUntil(self.clients.claim())
21
+ })
22
+
23
+ addEventListener('message', async function (event) {
24
+ const clientId = Reflect.get(event.source || {}, 'id')
25
+
26
+ if (!clientId || !self.clients) {
27
+ return
28
+ }
29
+
30
+ const client = await self.clients.get(clientId)
31
+
32
+ if (!client) {
33
+ return
34
+ }
35
+
36
+ const allClients = await self.clients.matchAll({
37
+ type: 'window',
38
+ })
39
+
40
+ switch (event.data) {
41
+ case 'KEEPALIVE_REQUEST': {
42
+ sendToClient(client, {
43
+ type: 'KEEPALIVE_RESPONSE',
44
+ })
45
+ break
46
+ }
47
+
48
+ case 'INTEGRITY_CHECK_REQUEST': {
49
+ sendToClient(client, {
50
+ type: 'INTEGRITY_CHECK_RESPONSE',
51
+ payload: {
52
+ packageVersion: PACKAGE_VERSION,
53
+ checksum: INTEGRITY_CHECKSUM,
54
+ },
55
+ })
56
+ break
57
+ }
58
+
59
+ case 'MOCK_ACTIVATE': {
60
+ activeClientIds.add(clientId)
61
+
62
+ sendToClient(client, {
63
+ type: 'MOCKING_ENABLED',
64
+ payload: {
65
+ client: {
66
+ id: client.id,
67
+ frameType: client.frameType,
68
+ },
69
+ },
70
+ })
71
+ break
72
+ }
73
+
74
+ case 'CLIENT_CLOSED': {
75
+ activeClientIds.delete(clientId)
76
+
77
+ const remainingClients = allClients.filter((client) => {
78
+ return client.id !== clientId
79
+ })
80
+
81
+ // Unregister itself when there are no more clients
82
+ if (remainingClients.length === 0) {
83
+ self.registration.unregister()
84
+ }
85
+
86
+ break
87
+ }
88
+ }
89
+ })
90
+
91
+ addEventListener('fetch', function (event) {
92
+ const requestInterceptedAt = Date.now()
93
+
94
+ // Bypass navigation requests.
95
+ if (event.request.mode === 'navigate') {
96
+ return
97
+ }
98
+
99
+ // Opening the DevTools triggers the "only-if-cached" request
100
+ // that cannot be handled by the worker. Bypass such requests.
101
+ if (
102
+ event.request.cache === 'only-if-cached' &&
103
+ event.request.mode !== 'same-origin'
104
+ ) {
105
+ return
106
+ }
107
+
108
+ // Bypass all requests when there are no active clients.
109
+ // Prevents the self-unregistered worked from handling requests
110
+ // after it's been terminated (still remains active until the next reload).
111
+ if (activeClientIds.size === 0) {
112
+ return
113
+ }
114
+
115
+ const requestId = crypto.randomUUID()
116
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
117
+ })
118
+
119
+ /**
120
+ * @param {FetchEvent} event
121
+ * @param {string} requestId
122
+ * @param {number} requestInterceptedAt
123
+ */
124
+ async function handleRequest(event, requestId, requestInterceptedAt) {
125
+ const client = await resolveMainClient(event)
126
+ const requestCloneForEvents = event.request.clone()
127
+ const response = await getResponse(
128
+ event,
129
+ client,
130
+ requestId,
131
+ requestInterceptedAt,
132
+ )
133
+
134
+ // Send back the response clone for the "response:*" life-cycle events.
135
+ // Ensure MSW is active and ready to handle the message, otherwise
136
+ // this message will pend indefinitely.
137
+ if (client && activeClientIds.has(client.id)) {
138
+ const serializedRequest = await serializeRequest(requestCloneForEvents)
139
+
140
+ // Omit the body of server-sent event stream responses.
141
+ // Cloning such responses would prevent client-side stream cancelations
142
+ // from reaching the original stream (a teed stream only cancels its
143
+ // source once both of its branches cancel) and would buffer the
144
+ // entire stream into the unconsumed clone indefinitely.
145
+ const isEventStreamResponse = response.headers
146
+ .get('content-type')
147
+ ?.toLowerCase()
148
+ .startsWith('text/event-stream')
149
+
150
+ // Clone the response so both the client and the library could consume it.
151
+ const responseClone = isEventStreamResponse ? null : response.clone()
152
+
153
+ sendToClient(
154
+ client,
155
+ {
156
+ type: 'RESPONSE',
157
+ payload: {
158
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
159
+ request: {
160
+ id: requestId,
161
+ ...serializedRequest,
162
+ },
163
+ response: {
164
+ type: response.type,
165
+ status: response.status,
166
+ statusText: response.statusText,
167
+ headers: Object.fromEntries(response.headers.entries()),
168
+ body: responseClone ? responseClone.body : null,
169
+ },
170
+ },
171
+ },
172
+ responseClone && responseClone.body
173
+ ? [serializedRequest.body, responseClone.body]
174
+ : [],
175
+ )
176
+ }
177
+
178
+ return response
179
+ }
180
+
181
+ /**
182
+ * Resolve the main client for the given event.
183
+ * Client that issues a request doesn't necessarily equal the client
184
+ * that registered the worker. It's with the latter the worker should
185
+ * communicate with during the response resolving phase.
186
+ * @param {FetchEvent} event
187
+ * @returns {Promise<Client | undefined>}
188
+ */
189
+ async function resolveMainClient(event) {
190
+ const client = await self.clients.get(event.clientId)
191
+
192
+ if (activeClientIds.has(event.clientId)) {
193
+ return client
194
+ }
195
+
196
+ if (client?.frameType === 'top-level') {
197
+ return client
198
+ }
199
+
200
+ const allClients = await self.clients.matchAll({
201
+ type: 'window',
202
+ })
203
+
204
+ return allClients
205
+ .filter((client) => {
206
+ // Get only those clients that are currently visible.
207
+ return client.visibilityState === 'visible'
208
+ })
209
+ .find((client) => {
210
+ // Find the client ID that's recorded in the
211
+ // set of clients that have registered the worker.
212
+ return activeClientIds.has(client.id)
213
+ })
214
+ }
215
+
216
+ /**
217
+ * @param {FetchEvent} event
218
+ * @param {Client | undefined} client
219
+ * @param {string} requestId
220
+ * @param {number} requestInterceptedAt
221
+ * @returns {Promise<Response>}
222
+ */
223
+ async function getResponse(event, client, requestId, requestInterceptedAt) {
224
+ // Clone the request because it might've been already used
225
+ // (i.e. its body has been read and sent to the client).
226
+ const requestClone = event.request.clone()
227
+
228
+ function passthrough() {
229
+ // Cast the request headers to a new Headers instance
230
+ // so the headers can be manipulated with.
231
+ const headers = new Headers(requestClone.headers)
232
+
233
+ // Remove the "accept" header value that marked this request as passthrough.
234
+ // This prevents request alteration and also keeps it compliant with the
235
+ // user-defined CORS policies.
236
+ const acceptHeader = headers.get('accept')
237
+ if (acceptHeader) {
238
+ const values = acceptHeader.split(',').map((value) => value.trim())
239
+ const filteredValues = values.filter(
240
+ (value) => value !== 'msw/passthrough',
241
+ )
242
+
243
+ if (filteredValues.length > 0) {
244
+ headers.set('accept', filteredValues.join(', '))
245
+ } else {
246
+ headers.delete('accept')
247
+ }
248
+ }
249
+
250
+ return fetch(requestClone, { headers })
251
+ }
252
+
253
+ // Bypass mocking when the client is not active.
254
+ if (!client) {
255
+ return passthrough()
256
+ }
257
+
258
+ // Bypass initial page load requests (i.e. static assets).
259
+ // The absence of the immediate/parent client in the map of the active clients
260
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
261
+ // and is not ready to handle requests.
262
+ if (!activeClientIds.has(client.id)) {
263
+ return passthrough()
264
+ }
265
+
266
+ // Notify the client that a request has been intercepted.
267
+ const serializedRequest = await serializeRequest(event.request)
268
+ const clientMessage = await sendToClient(
269
+ client,
270
+ {
271
+ type: 'REQUEST',
272
+ payload: {
273
+ id: requestId,
274
+ interceptedAt: requestInterceptedAt,
275
+ ...serializedRequest,
276
+ },
277
+ },
278
+ [serializedRequest.body],
279
+ )
280
+
281
+ switch (clientMessage.type) {
282
+ case 'MOCK_RESPONSE': {
283
+ return respondWithMock(clientMessage.data)
284
+ }
285
+
286
+ case 'PASSTHROUGH': {
287
+ return passthrough()
288
+ }
289
+ }
290
+
291
+ return passthrough()
292
+ }
293
+
294
+ /**
295
+ * @param {Client} client
296
+ * @param {any} message
297
+ * @param {Array<Transferable>} transferrables
298
+ * @returns {Promise<any>}
299
+ */
300
+ function sendToClient(client, message, transferrables = []) {
301
+ return new Promise((resolve, reject) => {
302
+ const channel = new MessageChannel()
303
+
304
+ channel.port1.onmessage = (event) => {
305
+ if (event.data && event.data.error) {
306
+ return reject(event.data.error)
307
+ }
308
+
309
+ resolve(event.data)
310
+ }
311
+
312
+ client.postMessage(message, [
313
+ channel.port2,
314
+ ...transferrables.filter(Boolean),
315
+ ])
316
+ })
317
+ }
318
+
319
+ /**
320
+ * @param {Response} response
321
+ * @returns {Response}
322
+ */
323
+ function respondWithMock(response) {
324
+ // Setting response status code to 0 is a no-op.
325
+ // However, when responding with a "Response.error()", the produced Response
326
+ // instance will have status code set to 0. Since it's not possible to create
327
+ // a Response instance with status code 0, handle that use-case separately.
328
+ if (response.status === 0) {
329
+ return Response.error()
330
+ }
331
+
332
+ const mockedResponse = new Response(response.body, response)
333
+
334
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
335
+ value: true,
336
+ enumerable: true,
337
+ })
338
+
339
+ return mockedResponse
340
+ }
341
+
342
+ /**
343
+ * @param {Request} request
344
+ */
345
+ async function serializeRequest(request) {
346
+ return {
347
+ url: request.url,
348
+ mode: request.mode,
349
+ method: request.method,
350
+ headers: Object.fromEntries(request.headers.entries()),
351
+ cache: request.cache,
352
+ credentials: request.credentials,
353
+ destination: request.destination,
354
+ integrity: request.integrity,
355
+ redirect: request.redirect,
356
+ referrer: request.referrer,
357
+ referrerPolicy: request.referrerPolicy,
358
+ body: await request.arrayBuffer(),
359
+ keepalive: request.keepalive,
360
+ }
361
+ }
public/mstile-150x150.png ADDED
public/robots.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
public/safari-pinned-tab.svg ADDED
public/site.webmanifest ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "",
3
+ "short_name": "",
4
+ "icons": [
5
+ {
6
+ "src": "/android-chrome-192x192.png",
7
+ "sizes": "192x192",
8
+ "type": "image/png"
9
+ },
10
+ {
11
+ "src": "/android-chrome-512x512.png",
12
+ "sizes": "512x512",
13
+ "type": "image/png"
14
+ }
15
+ ],
16
+ "theme_color": "#ffffff",
17
+ "background_color": "#ffffff",
18
+ "display": "standalone"
19
+ }
scripts/brand-dev-electron.mjs ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Brand the dev Electron app bundle with the product name (macOS only).
4
+ *
5
+ * Run automatically as the `predesktop` npm hook.
6
+ *
7
+ * WHY THIS EXISTS
8
+ *
9
+ * `npm run desktop` runs the app inside Electron's own prebuilt bundle,
10
+ * node_modules/electron/dist/Electron.app. macOS derives the name it shows
11
+ * in the Dock tooltip, the ⌘-Tab switcher and Finder from that bundle, at
12
+ * launch, before any JavaScript runs. No runtime API can change it β€”
13
+ * `app.setName()`, `app.name` and package.json `productName` only drive
14
+ * Electron's own notion of the name (menu bar, About panel, and
15
+ * app.getPath("userData")).
16
+ *
17
+ * The packaged app has never had this problem: electron-builder emits a
18
+ * bundle literally named "<productName>.app" with matching plist keys. This
19
+ * script puts the dev bundle in that same state.
20
+ *
21
+ * THE BUNDLE FILENAME IS THE PART THAT ACTUALLY SHOWS
22
+ *
23
+ * Patching the plist alone is NOT enough β€” verified the hard way. macOS
24
+ * prefers the bundle's filesystem name over CFBundleName/CFBundleDisplayName
25
+ * for the Dock tooltip. Two installed apps prove each half of this:
26
+ *
27
+ * DBeaver.app CFBundleName "DBeaver Community" β†’ displays "DBeaver"
28
+ * (the filename wins over the plist)
29
+ * Antigravity.app CFBundleExecutable "Electron" β†’ displays "Antigravity"
30
+ * (an Electron app whose executable name is irrelevant)
31
+ *
32
+ * So the fix aligns every source of the name at once: the .app directory
33
+ * name, CFBundleName and CFBundleDisplayName. That is exactly the shape of a
34
+ * packaged build, which is known to display correctly.
35
+ *
36
+ * Renaming the bundle means node_modules/electron/path.txt has to move with
37
+ * it: getElectronPath() in node_modules/electron/index.js joins path.txt onto
38
+ * dist/ and silently re-downloads Electron (~100 MB) if the result does not
39
+ * exist. The two are updated together, and the rename is rolled back if
40
+ * path.txt cannot be written.
41
+ *
42
+ * WHY THIS IS SAFE
43
+ *
44
+ * - Electron's prebuilt dist is ad-hoc *linker-signed*: `codesign -dv`
45
+ * reports `flags=0x20002(adhoc,linker-signed)`, `Info.plist=not bound`,
46
+ * `Sealed Resources=none`. The signature covers only the Mach-O, so
47
+ * editing Info.plist does not invalidate it and no re-signing is needed.
48
+ * - `npm run build:desktop` is unaffected: electron-builder packages from
49
+ * its own download cache (~/Library/Caches/electron/electron-v*.zip),
50
+ * never from node_modules/electron/dist.
51
+ *
52
+ * WHAT IT DELIBERATELY DOES NOT TOUCH
53
+ *
54
+ * CFBundleExecutable β€” left as "Electron". Antigravity above shows it has
55
+ * no bearing on the displayed name; it only feeds ps / Activity Monitor.
56
+ * Leaving it alone keeps path.txt's trailing segments valid.
57
+ * CFBundleIdentifier β€” kept at com.github.Electron. Changing it would split
58
+ * LaunchServices / TCC state per checkout for no visible gain.
59
+ *
60
+ * The changes live in node_modules, which `npm ci` wipes. That is fine: the
61
+ * `predesktop` hook re-applies them on every `npm run desktop`.
62
+ *
63
+ * This script must never block the desktop run β€” every failure path warns
64
+ * and exits 0.
65
+ */
66
+
67
+ import { execFileSync } from "node:child_process";
68
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
69
+ import { createRequire } from "node:module";
70
+ import { dirname, join } from "node:path";
71
+ import { fileURLToPath } from "node:url";
72
+
73
+ const __dirname = dirname(fileURLToPath(import.meta.url));
74
+ const projectRoot = join(__dirname, "..");
75
+
76
+ // Both keys are set. CFBundleDisplayName is the one LaunchServices reports;
77
+ // CFBundleName is the fallback and shows up in other bundle-name surfaces.
78
+ const NAME_KEYS = ["CFBundleDisplayName", "CFBundleName"];
79
+
80
+ function warn(message) {
81
+ console.warn(`[brand-dev-electron] ${message}`);
82
+ }
83
+
84
+ /** Root of the installed `electron` package, or null if it isn't resolvable. */
85
+ function resolveElectronPackage() {
86
+ const require = createRequire(import.meta.url);
87
+ return dirname(require.resolve("electron/package.json"));
88
+ }
89
+
90
+ /** Current value of `key`, or null when the key is absent. */
91
+ function readPlistString(plistPath, key) {
92
+ try {
93
+ return execFileSync(
94
+ "plutil",
95
+ ["-extract", key, "raw", "-o", "-", plistPath],
96
+ { encoding: "utf8" },
97
+ ).trim();
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function writePlistString(plistPath, key, value) {
104
+ execFileSync("plutil", ["-replace", key, "-string", value, plistPath], {
105
+ stdio: "pipe",
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Rename dist/<current>.app to dist/<productName>.app and repoint path.txt.
111
+ * Returns { appDir, renamed }, or null if the bundle can't be determined.
112
+ */
113
+ function ensureBundleName(pkgDir, productName) {
114
+ const distDir = join(pkgDir, "dist");
115
+ const pathFile = join(pkgDir, "path.txt");
116
+ if (!existsSync(pathFile)) {
117
+ warn(`No ${pathFile} β€” leaving the dev bundle alone.`);
118
+ return null;
119
+ }
120
+
121
+ // e.g. "Electron.app/Contents/MacOS/Electron" β€” only the first segment
122
+ // (the bundle directory) is ours to rename.
123
+ const relative = readFileSync(pathFile, "utf8").trim();
124
+ const segments = relative.split("/");
125
+ const currentName = segments[0];
126
+ const desiredName = `${productName}.app`;
127
+ if (!currentName.endsWith(".app")) {
128
+ warn(`Unexpected path.txt entry "${relative}" β€” leaving the bundle alone.`);
129
+ return null;
130
+ }
131
+
132
+ const desiredDir = join(distDir, desiredName);
133
+ if (currentName === desiredName && existsSync(desiredDir)) {
134
+ return { appDir: desiredDir, renamed: false };
135
+ }
136
+
137
+ const currentDir = join(distDir, currentName);
138
+ let renamed = false;
139
+ if (existsSync(currentDir) && currentDir !== desiredDir) {
140
+ if (existsSync(desiredDir)) {
141
+ // Both present: a previous run renamed the bundle and something
142
+ // restored the original. Prefer the correctly named one and just fix
143
+ // path.txt rather than clobbering either bundle.
144
+ warn(`Both ${currentName} and ${desiredName} exist β€” using the latter.`);
145
+ } else {
146
+ renameSync(currentDir, desiredDir);
147
+ renamed = true;
148
+ }
149
+ } else if (!existsSync(desiredDir)) {
150
+ warn(`No Electron bundle under ${distDir} β€” leaving the dev bundle alone.`);
151
+ return null;
152
+ }
153
+
154
+ // path.txt MUST agree with the directory on disk; a stale entry makes
155
+ // getElectronPath() re-download Electron on the next run.
156
+ segments[0] = desiredName;
157
+ try {
158
+ writeFileSync(pathFile, segments.join("/"));
159
+ } catch (err) {
160
+ // Undo the rename so the checkout is left in a working state.
161
+ if (existsSync(desiredDir) && !existsSync(currentDir)) {
162
+ try {
163
+ renameSync(desiredDir, currentDir);
164
+ } catch {
165
+ warn(`Could not roll back the bundle rename in ${distDir}.`);
166
+ }
167
+ }
168
+ warn(`Could not update ${pathFile}: ${err.message}`);
169
+ return null;
170
+ }
171
+
172
+ return { appDir: desiredDir, renamed };
173
+ }
174
+
175
+ function main() {
176
+ // The Dock/⌘-Tab name is a macOS bundle concept. On Windows the dev
177
+ // taskbar name comes from electron.exe's version resource and on Linux
178
+ // from the .desktop file / WM_CLASS β€” neither exists until the app is
179
+ // packaged, so there is nothing to patch.
180
+ if (process.platform !== "darwin") return;
181
+
182
+ const manifestPath = join(projectRoot, "electron", "package.json");
183
+ let productName;
184
+ try {
185
+ productName = JSON.parse(readFileSync(manifestPath, "utf8")).productName;
186
+ } catch (err) {
187
+ warn(`Could not read ${manifestPath}: ${err.message}`);
188
+ return;
189
+ }
190
+ if (!productName) {
191
+ warn(`No productName in ${manifestPath} β€” leaving the dev bundle alone.`);
192
+ return;
193
+ }
194
+ // The name becomes a directory entry; a "/" would silently retarget it.
195
+ if (productName.includes("/")) {
196
+ warn(`productName "${productName}" cannot be used as a bundle name.`);
197
+ return;
198
+ }
199
+
200
+ let pkgDir;
201
+ try {
202
+ pkgDir = resolveElectronPackage();
203
+ } catch (err) {
204
+ warn(`Could not resolve the electron package: ${err.message}`);
205
+ return;
206
+ }
207
+
208
+ let bundle;
209
+ try {
210
+ bundle = ensureBundleName(pkgDir, productName);
211
+ } catch (err) {
212
+ warn(`Could not rename the dev bundle: ${err.message}`);
213
+ return;
214
+ }
215
+ if (!bundle) return;
216
+
217
+ const plistPath = join(bundle.appDir, "Contents", "Info.plist");
218
+ if (!existsSync(plistPath)) {
219
+ warn(`No Info.plist at ${plistPath} β€” leaving the plist alone.`);
220
+ return;
221
+ }
222
+
223
+ const stale = NAME_KEYS.filter(
224
+ (key) => readPlistString(plistPath, key) !== productName,
225
+ );
226
+
227
+ try {
228
+ for (const key of stale) writePlistString(plistPath, key, productName);
229
+ } catch (err) {
230
+ // Read-only node_modules (CI caches, sandboxes) lands here. The app still
231
+ // runs; only the displayed name keeps saying "Electron".
232
+ warn(`Could not patch ${plistPath}: ${err.message}`);
233
+ return;
234
+ }
235
+
236
+ // Stay quiet when there was nothing to do, so repeat runs don't add noise.
237
+ if (bundle.renamed || stale.length > 0) {
238
+ console.log(
239
+ `[brand-dev-electron] Dev Electron bundle now identifies as "${productName}".`,
240
+ );
241
+ }
242
+ }
243
+
244
+ main();
scripts/check-sdk-version-sync.mjs ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Check SDK Version Sync
5
+ *
6
+ * Verifies two things against versions.agentServer in config/defaults.json:
7
+ *
8
+ * 1. The local @openhands/typescript-client pin in package.json. Canvas renders
9
+ * the ACP provider picker from that generated registry mirror but launches
10
+ * the adapter through the agent-server image, so a skew ships a picker
11
+ * offering models and launch commands agent-server does not implement.
12
+ *
13
+ * 2. That the released automation package (openhands-automation on PyPI)
14
+ * uses the SDK version expected for that automation release for all agent SDK libraries:
15
+ * - openhands-sdk
16
+ * - openhands-tools
17
+ * - openhands-workspace
18
+ * - openhands-agent-server
19
+ *
20
+ * This script checks the RELEASED PyPI version of openhands-automation (as specified
21
+ * by versions.automation in config/defaults.json), not the main branch.
22
+ * The expected SDK dependency version is versions.agentServer β€” the two must
23
+ * always match, so this script catches any drift.
24
+ *
25
+ * This script is run in CI to catch version drift between projects.
26
+ *
27
+ * Usage:
28
+ * node scripts/check-sdk-version-sync.mjs
29
+ * EXPECTED_SDK_VERSION=1.46.0 node scripts/check-sdk-version-sync.mjs
30
+ * node scripts/check-sdk-version-sync.mjs --check-pypi
31
+ *
32
+ * Environment variables:
33
+ * EXPECTED_SDK_VERSION - Override the expected version (instead of reading from config/defaults.json)
34
+ * AUTOMATION_PACKAGE_NAME - Override the automation package name (default: openhands-automation)
35
+ * AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from config/defaults.json)
36
+ *
37
+ * Options:
38
+ * --check-pypi Also check the latest SDK version on PyPI
39
+ * --help Show help
40
+ *
41
+ * Exit codes:
42
+ * 0 - All SDK versions match
43
+ * 1 - Version mismatch detected or error occurred
44
+ */
45
+
46
+ import { readFileSync } from "node:fs";
47
+ import { dirname, join } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import process from "node:process";
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url));
52
+ const projectRoot = join(__dirname, "..");
53
+
54
+ // Parse command line arguments
55
+ const args = process.argv.slice(2);
56
+ const checkPyPI = args.includes("--check-pypi");
57
+ const showHelp = args.includes("--help") || args.includes("-h");
58
+
59
+ if (showHelp) {
60
+ console.log(`
61
+ SDK Version Sync Check
62
+
63
+ Verifies that the released openhands-automation package on PyPI uses the
64
+ SDK version expected for that automation release.
65
+
66
+ The automation version is read from config/defaults.json (versions.automation).
67
+ The expected SDK dependency version is read from versions.agentServer.
68
+
69
+ Usage:
70
+ node scripts/check-sdk-version-sync.mjs [options]
71
+
72
+ Options:
73
+ --check-pypi Also check the latest SDK version on PyPI
74
+ --help, -h Show this help
75
+
76
+ Environment variables:
77
+ EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from config/defaults.json)
78
+ AUTOMATION_PACKAGE_NAME Override the automation package name (default: openhands-automation)
79
+ AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from config/defaults.json)
80
+
81
+ Triggering from other repos:
82
+ The automation repo or SDK repo can trigger this check via GitHub repository_dispatch:
83
+
84
+ curl -X POST \\
85
+ -H "Authorization: token \$GITHUB_TOKEN" \\
86
+ -H "Accept: application/vnd.github.v3+json" \\
87
+ https://api.github.com/repos/OpenHands/OpenHands/dispatches \\
88
+ -d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.46.0"}}'
89
+ `);
90
+ process.exit(0);
91
+ }
92
+
93
+ // ANSI color codes for terminal output
94
+ const colors = {
95
+ reset: "\x1b[0m",
96
+ red: "\x1b[31m",
97
+ green: "\x1b[32m",
98
+ yellow: "\x1b[33m",
99
+ cyan: "\x1b[36m",
100
+ dim: "\x1b[2m",
101
+ };
102
+
103
+ // SDK packages that must have matching versions
104
+ const SDK_PACKAGES = [
105
+ "openhands-sdk",
106
+ "openhands-tools",
107
+ "openhands-workspace",
108
+ "openhands-agent-server",
109
+ ];
110
+
111
+ // Mirrors the SDK's ACP provider registry. Must track versions.agentServer:
112
+ // the picker is rendered from this pin but the adapter is launched by that
113
+ // image, so a skew advertises models the running agent-server cannot run.
114
+ const CLIENT_PACKAGE_NAME = "@openhands/typescript-client";
115
+
116
+ // Configurable automation package (can be overridden via env)
117
+ const AUTOMATION_PACKAGE_NAME = process.env.AUTOMATION_PACKAGE_NAME || "openhands-automation";
118
+
119
+ // Default retry configuration
120
+ const RETRY_COUNT = 3;
121
+ const RETRY_DELAY_MS = 1000;
122
+
123
+ /**
124
+ * Normalize a version string for comparison.
125
+ * Handles variations like "1.22" vs "1.22.0" by ensuring consistent format.
126
+ */
127
+ function normalizeVersion(version) {
128
+ if (!version) return null;
129
+
130
+ // Remove any pre-release or build metadata for base comparison
131
+ const baseVersion = version.split(/[-+]/)[0];
132
+
133
+ // Split into parts and pad to 3 parts (major.minor.patch)
134
+ const parts = baseVersion.split(".").map((p) => parseInt(p, 10) || 0);
135
+ while (parts.length < 3) {
136
+ parts.push(0);
137
+ }
138
+
139
+ return parts.slice(0, 3).join(".");
140
+ }
141
+
142
+ /**
143
+ * Compare two versions for equality (handles semantic equivalence)
144
+ */
145
+ function versionsEqual(v1, v2) {
146
+ return normalizeVersion(v1) === normalizeVersion(v2);
147
+ }
148
+
149
+ /**
150
+ * Sleep for a given number of milliseconds
151
+ */
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
155
+
156
+ // ── Centralized config ──────────────────────────────────────────────────────
157
+ let SHARED_DEFAULTS;
158
+ try {
159
+ SHARED_DEFAULTS = JSON.parse(
160
+ readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
161
+ );
162
+ if (!SHARED_DEFAULTS.versions?.agentServer) {
163
+ throw new Error("missing required field: versions.agentServer");
164
+ }
165
+ } catch (err) {
166
+ console.error(`${colors.red}Failed to load config/defaults.json: ${err.message}${colors.reset}`);
167
+ console.error("Ensure the file exists and contains valid JSON with required fields.");
168
+ process.exit(1);
169
+ }
170
+
171
+ /**
172
+ * Read the expected automation SDK dependency version from environment
173
+ * or config/defaults.json.
174
+ */
175
+ function getExpectedVersion() {
176
+ // Allow override via environment variable (useful for CI triggers).
177
+ const envVersion = process.env.EXPECTED_SDK_VERSION;
178
+ if (envVersion && envVersion.trim()) {
179
+ return { version: envVersion.trim(), source: "EXPECTED_SDK_VERSION env var" };
180
+ }
181
+
182
+ return {
183
+ version: SHARED_DEFAULTS.versions.agentServer,
184
+ source: "config/defaults.json (versions.agentServer)",
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Compare the local typescript-client pin against the expected SDK version.
190
+ * Returns a mismatch descriptor, or null when they agree.
191
+ */
192
+ function findClientPinMismatch(pinnedVersion, expectedVersion) {
193
+ if (!pinnedVersion) {
194
+ return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: null };
195
+ }
196
+ // A range would reintroduce the skew this check exists to catch.
197
+ if (!/^[0-9]/.test(pinnedVersion)) {
198
+ return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: pinnedVersion };
199
+ }
200
+ if (versionsEqual(pinnedVersion, expectedVersion)) {
201
+ return null;
202
+ }
203
+ return { package: CLIENT_PACKAGE_NAME, expected: expectedVersion, actual: pinnedVersion };
204
+ }
205
+
206
+ /**
207
+ * Read the typescript-client pin from package.json.
208
+ */
209
+ function readClientPin() {
210
+ const pkg = JSON.parse(
211
+ readFileSync(join(projectRoot, "package.json"), "utf-8"),
212
+ );
213
+ return pkg.dependencies?.[CLIENT_PACKAGE_NAME] ?? null;
214
+ }
215
+
216
+ /**
217
+ * Fetch the latest version of a package from PyPI
218
+ */
219
+ async function fetchPyPIVersion(packageName) {
220
+ const url = `https://pypi.org/pypi/${packageName}/json`;
221
+ try {
222
+ const response = await fetch(url);
223
+ if (!response.ok) {
224
+ return null;
225
+ }
226
+ const data = await response.json();
227
+ return data.info?.version || null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Read the automation version from env var or config/defaults.json
235
+ */
236
+ function getAutomationVersion() {
237
+ // Allow override via environment variable
238
+ const envVersion = process.env.AUTOMATION_PACKAGE_VERSION;
239
+ if (envVersion && envVersion.trim()) {
240
+ return { version: envVersion.trim(), source: "AUTOMATION_PACKAGE_VERSION env var" };
241
+ }
242
+
243
+ return {
244
+ version: SHARED_DEFAULTS.versions.automation,
245
+ source: "config/defaults.json (versions.automation)",
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Fetch package metadata from PyPI and extract dependencies (with retry)
251
+ */
252
+ async function fetchPyPIDependencies(packageName, version) {
253
+ const url = `https://pypi.org/pypi/${packageName}/${version}/json`;
254
+
255
+ console.log(`${colors.dim}Fetching ${url}${colors.reset}`);
256
+
257
+ let lastError;
258
+ for (let attempt = 0; attempt < RETRY_COUNT; attempt++) {
259
+ try {
260
+ const response = await fetch(url);
261
+
262
+ // 404 is a config issue, don't retry
263
+ if (response.status === 404) {
264
+ throw new Error(
265
+ `Package ${packageName}==${version} not found on PyPI (404). Check the package name and version.`,
266
+ );
267
+ }
268
+
269
+ if (!response.ok) {
270
+ throw new Error(
271
+ `Failed to fetch ${packageName}==${version} from PyPI: ${response.status} ${response.statusText}`,
272
+ );
273
+ }
274
+
275
+ const data = await response.json();
276
+ return data.info?.requires_dist || [];
277
+ } catch (err) {
278
+ lastError = err;
279
+
280
+ // Don't retry on 404 (config issue)
281
+ if (err.message.includes("not found on PyPI (404)")) {
282
+ throw err;
283
+ }
284
+
285
+ // Retry on other errors (network issues, 5xx, etc.)
286
+ if (attempt < RETRY_COUNT - 1) {
287
+ const delay = RETRY_DELAY_MS * (attempt + 1);
288
+ console.log(
289
+ `${colors.yellow}Retry ${attempt + 1}/${RETRY_COUNT - 1} after ${delay}ms...${colors.reset}`,
290
+ );
291
+ await sleep(delay);
292
+ }
293
+ }
294
+ }
295
+
296
+ throw lastError;
297
+ }
298
+
299
+ /**
300
+ * Parse PyPI requires_dist array and extract SDK package versions
301
+ *
302
+ * PyPI returns dependencies in PEP 508 format like:
303
+ * "openhands-sdk>=1.46.0,<2.0.0"
304
+ * "openhands-tools==1.46.0"
305
+ * "openhands-workspace (>=1.46.0)"
306
+ */
307
+ function parseSdkVersionsFromRequiresDist(requiresDist) {
308
+ const versions = {};
309
+
310
+ for (const pkg of SDK_PACKAGES) {
311
+ for (const dep of requiresDist) {
312
+ // Check if the dependency starts with our package name
313
+ // The package name may be followed by whitespace, operators, or parentheses
314
+ if (!dep.toLowerCase().startsWith(pkg.toLowerCase())) {
315
+ continue;
316
+ }
317
+
318
+ // Extract the version number - look for patterns like:
319
+ // ">=1.46.0", "==1.46.0", "(>=1.46.0)", "~=1.46.0"
320
+ // After the package name and before any comma or closing paren
321
+ const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/;
322
+ const match = dep.match(versionPattern);
323
+ if (match) {
324
+ versions[pkg] = match[1];
325
+ break;
326
+ }
327
+ }
328
+ }
329
+
330
+ return versions;
331
+ }
332
+
333
+ /**
334
+ * Main entry point
335
+ */
336
+ async function main() {
337
+ console.log("");
338
+ console.log(
339
+ `${colors.cyan}SDK Version Sync Check${colors.reset}`,
340
+ );
341
+ console.log("─".repeat(50));
342
+ console.log("");
343
+
344
+ try {
345
+ // Get expected version from env var or config/defaults.json
346
+ const { version: expectedVersion, source: versionSource } = getExpectedVersion();
347
+ console.log(
348
+ `Expected automation SDK version: ${colors.green}${expectedVersion}${colors.reset} (from ${versionSource})`,
349
+ );
350
+
351
+ // Offline, so it runs first and fails fast without the PyPI round trip.
352
+ const clientMismatch = findClientPinMismatch(readClientPin(), expectedVersion);
353
+ if (clientMismatch) {
354
+ console.log("");
355
+ console.log(
356
+ ` ${CLIENT_PACKAGE_NAME.padEnd(30)} ${colors.red}βœ— ${clientMismatch.actual ?? "(absent)"} (expected ${expectedVersion})${colors.reset}`,
357
+ );
358
+ console.log("");
359
+ console.log(`${colors.red}Version mismatch detected!${colors.reset}`);
360
+ console.log("");
361
+ console.log(
362
+ `${CLIENT_PACKAGE_NAME} mirrors the SDK's ACP provider registry that Canvas renders the`,
363
+ );
364
+ console.log(
365
+ `ACP picker from, but the adapter is launched by agent-server ${expectedVersion}. A skew ships a`,
366
+ );
367
+ console.log("picker offering models and launch commands that agent-server does not implement.");
368
+ console.log("");
369
+ console.log("To fix, update one of the following:");
370
+ console.log(` 1. Pin ${CLIENT_PACKAGE_NAME} to ${expectedVersion} in package.json`);
371
+ console.log(" 2. Update versions.agentServer in config/defaults.json");
372
+ console.log("");
373
+ process.exit(1);
374
+ }
375
+ console.log(
376
+ `Client registry pin: ${colors.green}${CLIENT_PACKAGE_NAME}@${expectedVersion}${colors.reset} (matches versions.agentServer)`,
377
+ );
378
+
379
+ // Get automation version from env var or config/defaults.json
380
+ const { version: automationVersion, source: automationSource } = getAutomationVersion();
381
+ console.log(
382
+ `Automation package: ${colors.cyan}${AUTOMATION_PACKAGE_NAME}==${automationVersion}${colors.reset} (from ${automationSource})`,
383
+ );
384
+
385
+ // Optionally check PyPI for the latest SDK version
386
+ if (checkPyPI) {
387
+ console.log("");
388
+ console.log("Checking latest SDK versions on PyPI:");
389
+ for (const pkg of SDK_PACKAGES) {
390
+ const pypiVersion = await fetchPyPIVersion(pkg);
391
+ if (pypiVersion) {
392
+ const status = versionsEqual(pypiVersion, expectedVersion)
393
+ ? colors.green
394
+ : colors.yellow;
395
+ console.log(` ${pkg.padEnd(25)} ${status}${pypiVersion}${colors.reset}`);
396
+ } else {
397
+ console.log(` ${pkg.padEnd(25)} ${colors.dim}(not found on PyPI)${colors.reset}`);
398
+ }
399
+ }
400
+ }
401
+
402
+ console.log("");
403
+
404
+ // Fetch automation package dependencies from PyPI
405
+ const requiresDist = await fetchPyPIDependencies(AUTOMATION_PACKAGE_NAME, automationVersion);
406
+ const automationVersions = parseSdkVersionsFromRequiresDist(requiresDist);
407
+
408
+ // Check each SDK package
409
+ let hasErrors = false;
410
+ let foundAny = false;
411
+ const mismatches = [];
412
+
413
+ console.log(`Checking ${AUTOMATION_PACKAGE_NAME}==${automationVersion} SDK dependencies:`);
414
+ console.log("");
415
+
416
+ for (const pkg of SDK_PACKAGES) {
417
+ const actualVersion = automationVersions[pkg];
418
+
419
+ if (actualVersion) {
420
+ foundAny = true;
421
+ if (versionsEqual(actualVersion, expectedVersion)) {
422
+ console.log(
423
+ ` ${pkg.padEnd(25)} ${colors.green}βœ“ ${actualVersion}${colors.reset}`,
424
+ );
425
+ } else {
426
+ hasErrors = true;
427
+ console.log(
428
+ ` ${pkg.padEnd(25)} ${colors.red}βœ— ${actualVersion} (expected ${expectedVersion})${colors.reset}`,
429
+ );
430
+ mismatches.push({
431
+ package: pkg,
432
+ expected: expectedVersion,
433
+ actual: actualVersion,
434
+ });
435
+ }
436
+ } else {
437
+ // Package not found - might be a transitive dependency, not an error
438
+ console.log(
439
+ ` ${pkg.padEnd(25)} ${colors.dim}- not a direct dependency${colors.reset}`,
440
+ );
441
+ }
442
+ }
443
+
444
+ console.log("");
445
+
446
+ if (!foundAny) {
447
+ console.log(
448
+ `${colors.yellow}Warning: No SDK packages found in ${AUTOMATION_PACKAGE_NAME}==${automationVersion} dependencies${colors.reset}`,
449
+ );
450
+ console.log("This might indicate a parsing issue or the package is not yet published.");
451
+ console.log("");
452
+ process.exit(1);
453
+ }
454
+
455
+ if (hasErrors) {
456
+ console.log(
457
+ `${colors.red}Version mismatch detected!${colors.reset}`,
458
+ );
459
+ console.log("");
460
+ console.log(`The released ${AUTOMATION_PACKAGE_NAME}==${automationVersion} uses different SDK versions than expected for that automation release.`);
461
+ console.log("");
462
+ console.log("Mismatched packages:");
463
+ for (const m of mismatches) {
464
+ console.log(` - ${m.package}: ${m.actual} (expected ${m.expected})`);
465
+ }
466
+ console.log("");
467
+ console.log("To fix, update one of the following:");
468
+ console.log(
469
+ ` 1. Release a new version of ${AUTOMATION_PACKAGE_NAME} with SDK dependencies pinned to ${expectedVersion}`,
470
+ );
471
+ console.log(
472
+ ` 2. Update versions.automation in config/defaults.json to a newer release`,
473
+ );
474
+ console.log("");
475
+ process.exit(1);
476
+ }
477
+
478
+ console.log(
479
+ `${colors.green}All SDK versions are in sync!${colors.reset}`,
480
+ );
481
+ console.log("");
482
+ } catch (error) {
483
+ console.error(`${colors.red}Error: ${error.message}${colors.reset}`);
484
+ process.exit(1);
485
+ }
486
+ }
487
+
488
+ // Export for testing
489
+ export {
490
+ normalizeVersion,
491
+ versionsEqual,
492
+ parseSdkVersionsFromRequiresDist,
493
+ findClientPinMismatch,
494
+ readClientPin,
495
+ SDK_PACKAGES,
496
+ CLIENT_PACKAGE_NAME,
497
+ AUTOMATION_PACKAGE_NAME,
498
+ };
499
+
500
+ main();
scripts/check-translation-completeness.cjs ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pre-commit hook script to check for translation completeness
5
+ * This script ensures that all translation keys have entries for all supported languages
6
+ * and that values are actually translated rather than English copied to every language.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // Keys whose value is intentionally identical in every language (brand names,
13
+ // protocol/technical terms, placeholder-only format strings). Add a key here
14
+ // only when the English value is genuinely correct for all languages.
15
+ const IDENTICAL_VALUE_ALLOWLIST = new Set([
16
+ 'ACTION_MESSAGE$ACP_TOOL',
17
+ 'API$TAVILY_KEY_EXAMPLE',
18
+ 'API$TVLY_KEY_EXAMPLE',
19
+ 'AUTOMATIONS$DOWNLOAD_TARBALL',
20
+ 'AUTOMATIONS$GIT_SYNC$BRANCH_PLACEHOLDER',
21
+ 'AUTOMATIONS$GIT_SYNC$PATH_PLACEHOLDER',
22
+ 'AUTOMATIONS$GIT_SYNC$REPO_URL_PLACEHOLDER',
23
+ 'BACKEND$CLOUD_TITLE',
24
+ 'BACKEND$VERSION_LABEL',
25
+ 'BRANDING$OPENHANDS',
26
+ 'COMMAND_MENU$SHORTCUT',
27
+ 'CONVERSATION$ACP_AGENT_GENERIC',
28
+ 'CONVERSATION$BUDGET_USAGE_FORMAT',
29
+ 'CONVERSATION$OVERVIEW_DIFF_ADDITIONS',
30
+ 'CONVERSATION$OVERVIEW_DIFF_DELETIONS',
31
+ 'CONVERSATION$OVERVIEW_GIT',
32
+ 'CONVERSATION$OVERVIEW_UNAVAILABLE',
33
+ 'CONVERSATION_PANEL$PREVIEW_GIT',
34
+ 'FILES$VSCODE',
35
+ 'GITHUB$AUTH_SCOPE',
36
+ 'LAUNCH$PLUGIN_PATH',
37
+ 'LAUNCH$PLUGIN_REF',
38
+ 'SCHEMA$LLM$SECTION_LABEL',
39
+ 'SCHEMA$LLM$TOP_K$LABEL',
40
+ 'SCHEMA$LLM$TOP_P$LABEL',
41
+ 'SCHEMA$SECURITY_ANALYZER$CHOICE$LLM',
42
+ 'SCHEMA$VERIFICATION$SECURITY_ANALYZER$CHOICE$LLM',
43
+ 'SETTINGS$AGENT_SERVER_URL_PLACEHOLDER',
44
+ 'SETTINGS$AGENT_TYPE_OPENHANDS',
45
+ 'SETTINGS$APP_UPDATE_CARD_TITLE',
46
+ 'SETTINGS$AZURE_DEVOPS',
47
+ 'SETTINGS$CLOUD_SETTINGS_LINK',
48
+ 'SETTINGS$VERSION_DOCKER',
49
+ 'SETTINGS$VERSION_NPM_RECOMMENDED',
50
+ 'SETTINGS$VERSION_PRODUCT_NAME',
51
+ 'SETTINGS$GITHUB',
52
+ 'SETTINGS$GITLAB',
53
+ 'SETTINGS$MCP_AUTH_MODE_OAUTH',
54
+ 'SETTINGS$MCP_DEFAULT_CONFIG',
55
+ 'SETTINGS$MCP_HEADERS_PLACEHOLDER',
56
+ 'SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER',
57
+ 'SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER',
58
+ 'SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER',
59
+ 'SETTINGS$MCP_SERVER_TYPE_SHTTP',
60
+ 'SETTINGS$MCP_SERVER_TYPE_SSE',
61
+ 'SETTINGS$MCP_SERVER_TYPE_STDIO',
62
+ 'SETTINGS$NAV_LLM',
63
+ 'SETTINGS$OPENHANDS_API_KEY_HELP_LINK',
64
+ 'SETTINGS$SKILLS_PILLS_MORE',
65
+ 'SETTINGS$SKILLS_VERSION',
66
+ 'SETTINGS$SLACK',
67
+ 'SETTINGS$TITLE_GENERATION_PROFILE_OPTION',
68
+ 'SETUP$REPOSITORY_PLACEHOLDER',
69
+ 'VSCODE$TITLE',
70
+ 'WORKSPACE$JUPYTER_TAB_LABEL',
71
+ ]);
72
+
73
+ // Extract the language codes from the AvailableLanguages array in the i18n index file
74
+ function getSupportedLanguageCodes() {
75
+ const i18nIndexPath = path.join(__dirname, '../src/i18n/index.ts');
76
+ const i18nIndexContent = fs.readFileSync(i18nIndexPath, 'utf8');
77
+
78
+ const languageCodesRegex = /\{ label: "[^"]+", value: "([^"]+)" \}/g;
79
+ const supportedLanguageCodes = [];
80
+ let match;
81
+
82
+ while ((match = languageCodesRegex.exec(i18nIndexContent)) !== null) {
83
+ supportedLanguageCodes.push(match[1]);
84
+ }
85
+
86
+ return supportedLanguageCodes;
87
+ }
88
+
89
+ // Check each translation key for missing languages, extra languages, and
90
+ // untranslated (English-copied) values
91
+ function checkTranslations(translationJson, supportedLanguageCodes) {
92
+ const missingTranslations = {};
93
+ const extraLanguages = {};
94
+ const untranslatedKeys = {};
95
+
96
+ const nonEnglishLanguageCodes = supportedLanguageCodes.filter(
97
+ (langCode) => langCode !== 'en'
98
+ );
99
+
100
+ Object.entries(translationJson).forEach(([key, translations]) => {
101
+ // Get the languages available for this key
102
+ const availableLanguages = Object.keys(translations);
103
+
104
+ // Find missing languages for this key
105
+ const missing = supportedLanguageCodes.filter(
106
+ (langCode) => !availableLanguages.includes(langCode)
107
+ );
108
+
109
+ if (missing.length > 0) {
110
+ missingTranslations[key] = missing;
111
+ }
112
+
113
+ // Find extra languages for this key
114
+ const extra = availableLanguages.filter(
115
+ (langCode) => !supportedLanguageCodes.includes(langCode)
116
+ );
117
+
118
+ if (extra.length > 0) {
119
+ extraLanguages[key] = extra;
120
+ }
121
+
122
+ // Flag keys where every non-English value is the English value copied
123
+ // verbatim β€” a strong signal the key was never translated. Keys whose value
124
+ // is legitimately identical everywhere belong in IDENTICAL_VALUE_ALLOWLIST.
125
+ if (
126
+ !IDENTICAL_VALUE_ALLOWLIST.has(key) &&
127
+ translations.en !== undefined &&
128
+ nonEnglishLanguageCodes.every(
129
+ (langCode) => translations[langCode] === translations.en
130
+ )
131
+ ) {
132
+ untranslatedKeys[key] = translations.en;
133
+ }
134
+ });
135
+
136
+ return { missingTranslations, extraLanguages, untranslatedKeys };
137
+ }
138
+
139
+ module.exports = {
140
+ IDENTICAL_VALUE_ALLOWLIST,
141
+ getSupportedLanguageCodes,
142
+ checkTranslations,
143
+ };
144
+
145
+ if (require.main === module) {
146
+ // Load the translation file
147
+ const translationJsonPath = path.join(__dirname, '../src/i18n/translation.json');
148
+ const translationJson = require(translationJsonPath);
149
+
150
+ const { missingTranslations, extraLanguages, untranslatedKeys } =
151
+ checkTranslations(translationJson, getSupportedLanguageCodes());
152
+
153
+ const hasErrors =
154
+ Object.keys(missingTranslations).length > 0 ||
155
+ Object.keys(extraLanguages).length > 0 ||
156
+ Object.keys(untranslatedKeys).length > 0;
157
+
158
+ // Generate detailed error message if there are missing translations
159
+ if (Object.keys(missingTranslations).length > 0) {
160
+ console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected');
161
+ console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`);
162
+
163
+ Object.entries(missingTranslations).forEach(([key, langs]) => {
164
+ console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`);
165
+ });
166
+
167
+ console.error('\nPlease add the missing translations before committing.');
168
+ }
169
+
170
+ // Generate detailed error message if there are extra languages
171
+ if (Object.keys(extraLanguages).length > 0) {
172
+ console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected');
173
+ console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`);
174
+
175
+ Object.entries(extraLanguages).forEach(([key, langs]) => {
176
+ console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`);
177
+ });
178
+
179
+ console.error('\nPlease remove the extra languages before committing.');
180
+ }
181
+
182
+ // Generate detailed error message if there are untranslated keys
183
+ if (Object.keys(untranslatedKeys).length > 0) {
184
+ console.error('\x1b[31m%s\x1b[0m', 'ERROR: Untranslated keys detected');
185
+ console.error(`Found ${Object.keys(untranslatedKeys).length} translation keys where the English value is copied to every language:`);
186
+
187
+ Object.entries(untranslatedKeys).forEach(([key, value]) => {
188
+ console.error(`- Key "${key}" has the same value ("${value}") for all languages`);
189
+ });
190
+
191
+ 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.');
192
+ }
193
+
194
+ // Exit with error code if there are issues
195
+ if (hasErrors) {
196
+ process.exit(1);
197
+ } else {
198
+ console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!');
199
+ }
200
+ }
scripts/dev-extra-backend.mjs ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import {
9
+ buildAgentServerCommand,
10
+ buildAgentServerEnv,
11
+ buildSafeDevConfig,
12
+ formatMissingUvxGuidance,
13
+ validateLocalAgentServerPath,
14
+ } from "./dev-safe.mjs";
15
+ import {
16
+ getProcessTreeSpawnOptions,
17
+ isProcessRunning,
18
+ signalProcessTree,
19
+ } from "./dev-process-utils.mjs";
20
+
21
+ const DEFAULT_EXTRA_BACKEND_PORT = 18002;
22
+ const DEFAULT_EXTRA_VSCODE_PORT = 18003;
23
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
24
+
25
+ function parsePort(value, fallback) {
26
+ if (value == null || value === "") {
27
+ return fallback;
28
+ }
29
+
30
+ const parsed = Number.parseInt(value, 10);
31
+ if (!Number.isInteger(parsed) || parsed <= 0) {
32
+ throw new Error(`Invalid port: ${value}`);
33
+ }
34
+
35
+ return parsed;
36
+ }
37
+
38
+ /**
39
+ * Build a config for an *extra* standalone agent-server that shares the
40
+ * bundled instance's persistence (state dir, conversations, secret key)
41
+ * but listens on a different backend + vscode port.
42
+ *
43
+ * @param {string} cwd
44
+ * @param {Record<string, string | undefined>} env
45
+ */
46
+ export function buildExtraBackendConfig(
47
+ cwd = process.cwd(),
48
+ env = process.env,
49
+ ) {
50
+ const base = buildSafeDevConfig(cwd, env);
51
+
52
+ const backendPort = parsePort(
53
+ env.OH_CANVAS_EXTRA_BACKEND_PORT,
54
+ DEFAULT_EXTRA_BACKEND_PORT,
55
+ );
56
+ const vscodePort = parsePort(
57
+ env.OH_CANVAS_EXTRA_VSCODE_PORT,
58
+ DEFAULT_EXTRA_VSCODE_PORT,
59
+ );
60
+
61
+ return {
62
+ ...base,
63
+ backendPort,
64
+ vscodePort,
65
+ backendBaseUrl: `http://127.0.0.1:${backendPort}`,
66
+ backendHost: `127.0.0.1:${backendPort}`,
67
+ };
68
+ }
69
+
70
+ function isEnoentError(error) {
71
+ return Boolean(
72
+ (error &&
73
+ typeof error === "object" &&
74
+ "code" in error &&
75
+ error.code === "ENOENT") ||
76
+ /ENOENT/.test(String(error)),
77
+ );
78
+ }
79
+
80
+ async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
81
+ const startedAt = Date.now();
82
+
83
+ while (Date.now() - startedAt < timeoutMs) {
84
+ try {
85
+ const response = await fetch(url);
86
+ if (response.ok) {
87
+ return;
88
+ }
89
+ } catch {
90
+ // Keep polling until timeout.
91
+ }
92
+
93
+ await delay(500);
94
+ }
95
+
96
+ throw new Error(`Timed out waiting for agent-server at ${url}`);
97
+ }
98
+
99
+ function spawnProcess(command, args, options = {}) {
100
+ const child = spawn(
101
+ command,
102
+ args,
103
+ getProcessTreeSpawnOptions({
104
+ stdio: "inherit",
105
+ ...options,
106
+ }),
107
+ );
108
+
109
+ child.once("error", (error) => {
110
+ if (isEnoentError(error) && command === "uvx") {
111
+ console.error(formatMissingUvxGuidance(options?.cwd));
112
+ } else if (isEnoentError(error)) {
113
+ console.error(
114
+ `Failed to start ${command}. Make sure it is installed and on your PATH.`,
115
+ );
116
+ } else {
117
+ console.error(`Failed to start ${command}:`, error);
118
+ }
119
+ });
120
+
121
+ return child;
122
+ }
123
+
124
+ async function main() {
125
+ const config = buildExtraBackendConfig();
126
+
127
+ if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
128
+ validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
129
+ }
130
+
131
+ for (const dir of [
132
+ config.stateDir,
133
+ config.tmuxTmpDir,
134
+ config.conversationsPath,
135
+ config.workspacesPath,
136
+ config.bashEventsDir,
137
+ ]) {
138
+ mkdirSync(dir, { recursive: true });
139
+ }
140
+
141
+ const agentServerCmd = buildAgentServerCommand();
142
+
143
+ const secretKeySource = process.env.OH_SECRET_KEY
144
+ ? "custom (from OH_SECRET_KEY)"
145
+ : "default (for local development)";
146
+
147
+ console.log("Starting EXTRA standalone agent-server (shared state)...");
148
+ console.log(`- agent-server: ${agentServerCmd.source}`);
149
+ console.log(`- backend: ${config.backendBaseUrl}`);
150
+ console.log(`- vscode port: ${config.vscodePort}`);
151
+ console.log(`- shared state dir: ${config.stateDir}`);
152
+ console.log(`- shared conversations: ${config.conversationsPath}`);
153
+ console.log(`- secret key: ${secretKeySource}`);
154
+ console.log("");
155
+ console.log(
156
+ "Connect via the GUI: open Add Backend, enter " +
157
+ `${config.backendBaseUrl} as the host. Leave the API key blank ` +
158
+ "unless this server is started with OH_SESSION_API_KEYS_0 set.",
159
+ );
160
+ console.log("");
161
+
162
+ const backend = spawnProcess(
163
+ agentServerCmd.command,
164
+ [
165
+ ...agentServerCmd.args,
166
+ "--host",
167
+ "127.0.0.1",
168
+ "--port",
169
+ String(config.backendPort),
170
+ ],
171
+ {
172
+ cwd: config.cwd,
173
+ env: {
174
+ // Deliberately not opting into the editor path prefix. This server is
175
+ // reached by registering it as an extra backend from a browser whose
176
+ // origin belongs to some *other* stack, so a prefix on that origin
177
+ // either does not resolve or β€” worse β€” resolves to the bundled
178
+ // stack's editor, silently handing back a different container's
179
+ // workspace. No single global prefix can disambiguate the two, so this
180
+ // launcher stays out of prefix-mode; the editor button is unavailable
181
+ // for conversations on an extra backend.
182
+ ...process.env,
183
+ ...buildAgentServerEnv(config),
184
+ },
185
+ },
186
+ );
187
+
188
+ let shuttingDown = false;
189
+
190
+ const shutdown = (signal = "SIGTERM") => {
191
+ if (shuttingDown) {
192
+ return;
193
+ }
194
+
195
+ shuttingDown = true;
196
+ signalProcessTree(backend, signal);
197
+
198
+ setTimeout(() => {
199
+ if (isProcessRunning(backend)) {
200
+ signalProcessTree(backend, "SIGKILL");
201
+ }
202
+ process.exit(process.exitCode ?? 0);
203
+ }, 3000);
204
+ };
205
+
206
+ process.on("SIGINT", () => shutdown("SIGINT"));
207
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
208
+ // The agent-server is spawned detached, so a SIGHUP that kills this launcher
209
+ // (terminal or multiplexer death) would otherwise leave it running and holding
210
+ // its port. Forward SIGTERM rather than SIGHUP: uvicorn only handles
211
+ // SIGINT/SIGTERM, so a forwarded SIGHUP would terminate the agent-server by
212
+ // default action instead of shutting it down gracefully.
213
+ process.on("SIGHUP", () => shutdown("SIGTERM"));
214
+
215
+ const backendErrored = new Promise((_, reject) => {
216
+ backend.once("error", (error) => reject(error));
217
+ });
218
+ const backendExited = new Promise((_, reject) => {
219
+ backend.once("exit", (code, signal) => {
220
+ if (!shuttingDown) {
221
+ reject(
222
+ new Error(
223
+ `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
224
+ ),
225
+ );
226
+ }
227
+ });
228
+ });
229
+
230
+ try {
231
+ await Promise.race([
232
+ waitForServer(`${config.backendBaseUrl}/server_info`),
233
+ backendErrored,
234
+ backendExited,
235
+ ]);
236
+ } catch (error) {
237
+ shutdown();
238
+ throw error;
239
+ }
240
+
241
+ console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`);
242
+
243
+ backend.once("exit", (code) => {
244
+ if (!shuttingDown) {
245
+ console.error(`agent-server exited unexpectedly with code ${code ?? 0}`);
246
+ shutdown();
247
+ process.exitCode = code ?? 1;
248
+ } else {
249
+ process.exitCode = code ?? 0;
250
+ }
251
+ });
252
+ }
253
+
254
+ if (
255
+ process.argv[1] &&
256
+ import.meta.url === pathToFileURL(process.argv[1]).href
257
+ ) {
258
+ main().catch((error) => {
259
+ console.error(error instanceof Error ? error.message : error);
260
+ process.exit(1);
261
+ });
262
+ }
scripts/dev-process-utils.mjs ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawnSync } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ /**
5
+ * Return true while Node still considers the child process active.
6
+ *
7
+ * Do not use ChildProcess#killed for cleanup decisions. In Node, `killed`
8
+ * only means a signal was sent successfully; it does not mean the process has
9
+ * exited. That distinction matters for dev launchers because uvx/npm
10
+ * wrappers can receive SIGTERM while their long-running child process keeps
11
+ * serving on the original port.
12
+ */
13
+ export function isProcessRunning(proc) {
14
+ return proc.exitCode === null && proc.signalCode === null;
15
+ }
16
+
17
+ /**
18
+ * Add spawn options needed for safe service launches and process-tree cleanup.
19
+ *
20
+ * Arguments must bypass shell parsing so values such as version constraints
21
+ * containing `<` are forwarded literally. Callers that need shell behavior
22
+ * must invoke the shell explicitly as the command.
23
+ *
24
+ * On POSIX, `detached: true` makes the spawned service the leader of a new
25
+ * process group. Later we can signal `-pid` to terminate that whole group,
26
+ * including wrapper chains like:
27
+ *
28
+ * launcher -> uvx -> python agent-server
29
+ * launcher -> npm -> sh -> Vite
30
+ *
31
+ * Windows does not support POSIX process groups, so callers fall back to
32
+ * signaling the direct child process there.
33
+ */
34
+ export function getProcessTreeSpawnOptions(options = {}) {
35
+ return {
36
+ ...options,
37
+ shell: false,
38
+ detached: process.platform !== "win32",
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Resolve a service command to a directly spawnable target on Windows.
44
+ *
45
+ * Services spawn without a shell so argument values reach the child verbatim.
46
+ * Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like
47
+ * `agent-client-protocol<0.11` is read as `<` input redirection and the spawn
48
+ * dies with "The system cannot find the file specified." Resolving to an
49
+ * absolute path lets callers spawn it shell-free.
50
+ *
51
+ * Returns `command` unchanged off Windows, when already a path, or if the lookup
52
+ * fails.
53
+ */
54
+ export function resolveWindowsCommand(
55
+ command,
56
+ platform = process.platform,
57
+ lookup = whereCommandLookup,
58
+ ) {
59
+ if (platform !== "win32") {
60
+ return command;
61
+ }
62
+ if (command.includes("/") || command.includes("\\")) {
63
+ return command;
64
+ }
65
+ return lookup(command) || command;
66
+ }
67
+
68
+ function whereCommandLookup(command) {
69
+ const result = spawnSync("where.exe", [command], { encoding: "utf8" });
70
+ if (result.status !== 0 || !result.stdout) {
71
+ return null;
72
+ }
73
+ return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null;
74
+ }
75
+
76
+ /**
77
+ * Signal the whole spawned service tree when possible.
78
+ *
79
+ * POSIX `process.kill(-pid, signal)` targets the process group whose id is
80
+ * `pid`; this only works because services are spawned with
81
+ * `getProcessTreeSpawnOptions()`. Without the negative pid, shutdown would
82
+ * often stop only the wrapper process and leave the actual server child
83
+ * listening on its port.
84
+ */
85
+ export function signalProcessTree(proc, signal) {
86
+ if (!isProcessRunning(proc)) {
87
+ return false;
88
+ }
89
+
90
+ try {
91
+ if (process.platform === "win32" && proc.pid) {
92
+ killWindowsProcessTree(proc, signal);
93
+ } else if (!proc.pid) {
94
+ proc.kill(signal);
95
+ } else {
96
+ process.kill(-proc.pid, signal);
97
+ }
98
+ return true;
99
+ } catch (err) {
100
+ if (err?.code === "ESRCH") {
101
+ return false;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Windows has no POSIX process groups: ChildProcess#kill reaches only the
109
+ * direct child (e.g. the uvx wrapper), leaving grandchildren β€” the actual
110
+ * python agent-server holding its port β€” running. `taskkill /t` walks the
111
+ * child tree instead. Windows also has no graceful tree signal (taskkill
112
+ * without /f posts WM_CLOSE, which console processes ignore), so SIGTERM and
113
+ * SIGKILL both map to the same forceful /f kill; callers' delayed SIGKILL
114
+ * pass skips already-exited trees via isProcessRunning, so the repeat is a
115
+ * no-op. A non-zero taskkill exit just means the tree already exited β€” only
116
+ * a failure to spawn taskkill itself falls back to the direct kill.
117
+ */
118
+ function killWindowsProcessTree(proc, signal) {
119
+ const result = spawnSync(
120
+ "taskkill",
121
+ ["/pid", String(proc.pid), "/t", "/f"],
122
+ // windowsHide avoids a console window flash when invoked from the
123
+ // packaged (GUI) Electron process.
124
+ { stdio: "ignore", windowsHide: true },
125
+ );
126
+ if (result.error) {
127
+ proc.kill(signal);
128
+ }
129
+ }
130
+
131
+ export function createShutdownHookRegistry(onError) {
132
+ const hooks = new Set();
133
+
134
+ return {
135
+ add(hook) {
136
+ hooks.add(hook);
137
+ return () => hooks.delete(hook);
138
+ },
139
+
140
+ run() {
141
+ for (const hook of hooks) {
142
+ try {
143
+ hook();
144
+ } catch (err) {
145
+ onError?.(err);
146
+ }
147
+ }
148
+ },
149
+ };
150
+ }
scripts/dev-safe.mjs ADDED
@@ -0,0 +1,1217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import net from "node:net";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import process from "node:process";
16
+ import { setTimeout as delay } from "node:timers/promises";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+
19
+ import {
20
+ getProcessTreeSpawnOptions,
21
+ isProcessRunning,
22
+ signalProcessTree,
23
+ } from "./dev-process-utils.mjs";
24
+ // buildRuntimeServicesInfo moved to its own dependency-free module so the
25
+ // Docker entrypoint can run it as a CLI. Re-exported below for back-compat
26
+ // (dev-with-automation.mjs and tests still import it from here).
27
+ import { buildRuntimeServicesInfo } from "./runtime-services-info.mjs";
28
+ import { fileLog, stripAnsi } from "./logger.mjs";
29
+
30
+ // ── Centralized config (single source of truth for versions, ports, etc.) ───
31
+ const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const SHARED_DEFAULTS = JSON.parse(
33
+ readFileSync(
34
+ path.join(__dev_safe_dirname, "..", "config", "defaults.json"),
35
+ "utf-8",
36
+ ),
37
+ );
38
+
39
+ const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
40
+ // Path prefix the bundled editor is served under. The same value has to reach
41
+ // agent-server (as OH_VSCODE_BASE_PATH, so openvscode-server is launched with
42
+ // --server-base-path and advertises the prefix) and the ingress route table,
43
+ // or the advertised URL and the route that serves it disagree.
44
+ export const VSCODE_BASE_PATH = SHARED_DEFAULTS.paths.vscodeBasePath;
45
+ const DEFAULT_VITE_PORT = 3001;
46
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
47
+ const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer;
48
+ const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk";
49
+ const LOCAL_AGENT_SERVER_SUBDIRS = [
50
+ "openhands-agent-server",
51
+ "openhands-sdk",
52
+ "openhands-tools",
53
+ "openhands-workspace",
54
+ ];
55
+ const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer;
56
+ // Temporary transitive-dep pin: openhands-sdk 1.40.1 leaves agent-client-protocol
57
+ // unbounded (>=0.10.1), but acp 0.11.0 reordered the ACP prompt() args and breaks
58
+ // the SDK's ACP client. Hold acp <0.11 until a fixed SDK ships. See config/defaults.json.
59
+ const AGENT_CLIENT_PROTOCOL_CONSTRAINT =
60
+ SHARED_DEFAULTS.constraints?.agentClientProtocol;
61
+ const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY =
62
+ SHARED_DEFAULTS.telemetry.posthogApiKey;
63
+ const DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST =
64
+ SHARED_DEFAULTS.telemetry.posthogHost;
65
+ const AGENT_SERVER_POSTHOG_CONSTRAINT = "posthog>=6,<7";
66
+ const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"];
67
+
68
+ /**
69
+ * Generate a cryptographically secure random API key.
70
+ * Returns a 64-character hex string (256-bit).
71
+ */
72
+ export function generateRandomApiKey() {
73
+ return randomBytes(32).toString("hex");
74
+ }
75
+
76
+ // Where the auto-generated API key is persisted so it stays stable across
77
+ // `npm run dev` restarts. Keeping the key stable means the value baked into
78
+ // the frontend (VITE_SESSION_API_KEY) and the persisted backend-registry entry
79
+ // (`openhands-backends` localStorage) stay in sync without users needing to
80
+ // set anything in `.env`.
81
+ //
82
+ // To rotate the key, delete this file. To pin a key explicitly, export
83
+ // LOCAL_BACKEND_API_KEY β€” it takes precedence over the persisted file.
84
+ export const DEFAULT_API_KEY_PATH = path.join(
85
+ homedir(),
86
+ ".openhands",
87
+ "agent-canvas",
88
+ "api-key.txt",
89
+ );
90
+
91
+ /** @deprecated Use DEFAULT_API_KEY_PATH */
92
+ export const DEFAULT_SESSION_API_KEY_PATH = DEFAULT_API_KEY_PATH;
93
+
94
+ // Where the OH_SECRET_KEY is persisted so dev mode and Docker mode share the
95
+ // same encryption key when both use ~/.openhands as their state directory.
96
+ // docker/entrypoint.sh reads and writes this same file, so whichever mode runs
97
+ // first generates the key and the other picks it up automatically.
98
+ //
99
+ // To rotate the key, delete this file and restart both modes. To pin a key
100
+ // explicitly, export OH_SECRET_KEY β€” that takes precedence over the file.
101
+ export const DEFAULT_SECRET_KEY_PATH = path.join(
102
+ homedir(),
103
+ ".openhands",
104
+ "agent-canvas",
105
+ "secret-key.txt",
106
+ );
107
+
108
+ // Cache so repeated lookups within a single process return the same key,
109
+ // keyed by file path so tests can use temp paths in isolation.
110
+ const persistedApiKeyCache = new Map();
111
+
112
+ /**
113
+ * Load the persisted default API key, generating + persisting one if the file
114
+ * doesn't exist yet.
115
+ *
116
+ * Best-effort: if the file can't be written (e.g. read-only home dir), we
117
+ * fall back to an in-memory key for this process so dev still works -- the
118
+ * key just won't survive a restart.
119
+ *
120
+ * @param {string} filePath - Where to read/write the key.
121
+ * @returns {string} The (hex) API key.
122
+ */
123
+ export function getOrCreatePersistedApiKeyFile(
124
+ filePath = DEFAULT_API_KEY_PATH,
125
+ ) {
126
+ return getOrCreatePersistedApiKey(filePath, "session");
127
+ }
128
+
129
+ /** @deprecated Use getOrCreatePersistedApiKeyFile */
130
+ export function getOrCreatePersistedSessionApiKey(
131
+ filePath = DEFAULT_API_KEY_PATH,
132
+ ) {
133
+ return getOrCreatePersistedApiKeyFile(filePath);
134
+ }
135
+
136
+ /**
137
+ * Load a persisted default API key, generating + persisting one if the file
138
+ * doesn't exist yet.
139
+ *
140
+ * Best-effort: if the file can't be written (e.g. read-only home dir), we
141
+ * fall back to an in-memory key for this process so dev still works -- the
142
+ * key just won't survive a restart.
143
+ *
144
+ * @param {string} filePath - Where to read/write the key.
145
+ * @param {string} label - Human-readable key label for warning messages.
146
+ * @returns {string} The (hex) API key.
147
+ */
148
+ export function getOrCreatePersistedApiKey(filePath, label = "API") {
149
+ const cached = persistedApiKeyCache.get(filePath);
150
+ if (cached) return cached;
151
+
152
+ // Try to read an existing key.
153
+ try {
154
+ const existing = readFileSync(filePath, "utf8").trim();
155
+ if (existing) {
156
+ persistedApiKeyCache.set(filePath, existing);
157
+ return existing;
158
+ }
159
+ // File exists but is empty -- treat as if missing and regenerate.
160
+ } catch (error) {
161
+ if (!isEnoentError(error)) {
162
+ console.warn(
163
+ `Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`,
164
+ );
165
+ }
166
+ }
167
+
168
+ // Generate and persist a new key.
169
+ const newKey = generateRandomApiKey();
170
+ try {
171
+ mkdirSync(path.dirname(filePath), { recursive: true });
172
+ writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 });
173
+ } catch (error) {
174
+ console.warn(
175
+ `Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`,
176
+ );
177
+ }
178
+ persistedApiKeyCache.set(filePath, newKey);
179
+ return newKey;
180
+ }
181
+
182
+ /**
183
+ * Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}.
184
+ * Intended for tests that swap the persisted file path between cases.
185
+ */
186
+ export function resetPersistedSessionApiKeyCache() {
187
+ persistedApiKeyCache.clear();
188
+ }
189
+
190
+ function isEnoentError(error) {
191
+ return Boolean(
192
+ (error &&
193
+ typeof error === "object" &&
194
+ "code" in error &&
195
+ error.code === "ENOENT") ||
196
+ /ENOENT/.test(String(error)),
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Find a free port, preferring the specified port if available.
202
+ *
203
+ * Tries the preferred port first; if it's busy, falls back to letting
204
+ * the OS assign any available port. This preserves predictable defaults
205
+ * while gracefully handling port conflicts.
206
+ *
207
+ * **Note on race conditions:** There is a small window between when this
208
+ * function checks port availability and when the calling service actually
209
+ * binds to the port. During this window, another process could theoretically
210
+ * grab the port. This is an accepted limitation of the "check-then-use"
211
+ * approach. Callers (like agent-server) should handle EADDRINUSE gracefully.
212
+ * For Vite, `strictPort: true` ensures a fast failure if this occurs.
213
+ *
214
+ * @param {number} preferredPort - The port to try first
215
+ * @param {string} host - The host to bind to (default: "127.0.0.1")
216
+ * @returns {Promise<number>} The actual port that was acquired
217
+ */
218
+ export async function findFreePort(preferredPort, host = "127.0.0.1") {
219
+ // If preferredPort is 0, skip the check and go straight to OS assignment
220
+ if (preferredPort > 0) {
221
+ const preferredAvailable = await tryPort(preferredPort, host);
222
+ if (preferredAvailable) {
223
+ return preferredPort;
224
+ }
225
+ }
226
+
227
+ // Fall back to OS-assigned port
228
+ return new Promise((resolve, reject) => {
229
+ const server = net.createServer();
230
+ server.once("error", reject);
231
+ server.listen(0, host, () => {
232
+ const { port } = server.address();
233
+ server.close(() => resolve(port));
234
+ });
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Check if a port is available by attempting to bind to it.
240
+ *
241
+ * @param {number} port - The port to check
242
+ * @param {string} host - The host to bind to
243
+ * @returns {Promise<boolean>} True if the port is available
244
+ */
245
+ function tryPort(port, host = "127.0.0.1") {
246
+ return new Promise((resolve) => {
247
+ const server = net.createServer();
248
+ server.once("error", () => resolve(false));
249
+ server.listen(port, host, () => {
250
+ server.close(() => resolve(true));
251
+ });
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Assert that all listed ports are available, throwing a descriptive error if
257
+ * any are already in use.
258
+ *
259
+ * Intended as a pre-flight check before spawning services so that a concurrent
260
+ * agent-canvas instance is detected immediately rather than silently starting
261
+ * on a different port.
262
+ *
263
+ * @param {Array<{name: string, port: number}>} portConfigs - Named port list
264
+ * @param {string} [host]
265
+ */
266
+ export async function assertPortsFree(portConfigs, host = "127.0.0.1") {
267
+ const results = await Promise.all(
268
+ portConfigs.map(async ({ name, port }) => ({
269
+ name,
270
+ port,
271
+ free: await tryPort(port, host),
272
+ })),
273
+ );
274
+ const busy = results.filter(({ free }) => !free);
275
+ if (busy.length === 0) return;
276
+
277
+ const lines = busy
278
+ .map(({ name, port }) => ` β€’ ${name}: port ${port}`)
279
+ .join("\n");
280
+ throw new Error(
281
+ `Cannot start: the following ports are already in use:\n\n${lines}\n\n` +
282
+ `Another agent-canvas instance may already be running.\n` +
283
+ `Stop it first, or override the port via environment variables (e.g. PORT=<other>).`,
284
+ );
285
+ }
286
+
287
+ /**
288
+ * Find multiple free ports at once, each preferring its specified default.
289
+ *
290
+ * Allocates ports sequentially to avoid race conditions between checks.
291
+ *
292
+ * @param {Array<{name: string, preferred: number}>} portConfigs - Port configurations
293
+ * @param {string} host - The host to bind to (default: "127.0.0.1")
294
+ * @returns {Promise<Record<string, number>>} Map of name to actual port
295
+ */
296
+ export async function findFreePorts(portConfigs, host = "127.0.0.1") {
297
+ const result = {};
298
+ const usedPorts = new Set();
299
+
300
+ for (const { name, preferred } of portConfigs) {
301
+ // Try preferred if not already taken by a previous allocation
302
+ // Skip if preferred is 0 (means "any port") or already used
303
+ if (preferred > 0 && !usedPorts.has(preferred)) {
304
+ const available = await tryPort(preferred, host);
305
+ if (available) {
306
+ result[name] = preferred;
307
+ usedPorts.add(preferred);
308
+ continue;
309
+ }
310
+ }
311
+
312
+ // Fall back to OS-assigned port, retrying if we get a collision
313
+ let port;
314
+ let attempts = 0;
315
+ const maxAttempts = 100;
316
+ do {
317
+ port = await findFreePort(0, host);
318
+ if (++attempts > maxAttempts) {
319
+ throw new Error(
320
+ `Could not allocate unique port for "${name}" after ${maxAttempts} attempts`,
321
+ );
322
+ }
323
+ } while (usedPorts.has(port));
324
+
325
+ result[name] = port;
326
+ usedPorts.add(port);
327
+ }
328
+
329
+ return result;
330
+ }
331
+
332
+ export function formatMissingUvxGuidance(cwd = process.cwd()) {
333
+ const readmePath = path.join(cwd, "README.md");
334
+
335
+ return [
336
+ "Failed to start uvx. Make sure uv is installed and on your PATH.",
337
+ "",
338
+ "To fix this:",
339
+ "1. Install uv:",
340
+ " curl -LsSf https://astral.sh/uv/install.sh | sh",
341
+ "2. Make sure the uv bin dir is on your PATH:",
342
+ ' export PATH="$HOME/.local/bin:$PATH"',
343
+ " command -v uvx",
344
+ "",
345
+ "Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/",
346
+ `See the local Quickstart for details: ${readmePath}`,
347
+ "",
348
+ "Other options:",
349
+ "- npm run dev:frontend # use an already running backend",
350
+ "- npm run dev:mock # run the frontend with mock APIs",
351
+ ].join("\n");
352
+ }
353
+
354
+ function npmBinCandidates(binName, platform = process.platform) {
355
+ const candidates = [binName];
356
+ if (platform === "win32") {
357
+ candidates.push(`${binName}.cmd`, `${binName}.ps1`);
358
+ }
359
+ return candidates;
360
+ }
361
+
362
+ export function getMissingFrontendDependencyBins(
363
+ cwd = process.cwd(),
364
+ platform = process.platform,
365
+ ) {
366
+ const binDir = path.join(cwd, "node_modules", ".bin");
367
+ return FRONTEND_REQUIRED_BINS.filter(
368
+ (binName) =>
369
+ !npmBinCandidates(binName, platform).some((candidate) =>
370
+ existsSync(path.join(binDir, candidate)),
371
+ ),
372
+ );
373
+ }
374
+
375
+ export function formatMissingFrontendDependenciesGuidance(
376
+ missingBins,
377
+ cwd = process.cwd(),
378
+ ) {
379
+ const missingList = missingBins.join(", ");
380
+ return [
381
+ "Frontend dependencies are not installed or are incomplete.",
382
+ "",
383
+ `Missing npm binaries: ${missingList}`,
384
+ "",
385
+ "Run this from the repository root:",
386
+ " npm ci",
387
+ "",
388
+ `Repository root: ${cwd}`,
389
+ ].join("\n");
390
+ }
391
+
392
+ export function validateFrontendDependencies(
393
+ cwd = process.cwd(),
394
+ platform = process.platform,
395
+ ) {
396
+ const missingBins = getMissingFrontendDependencyBins(cwd, platform);
397
+ if (missingBins.length > 0) {
398
+ throw new Error(
399
+ formatMissingFrontendDependenciesGuidance(missingBins, cwd),
400
+ );
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Modules the agent-server imports at startup (`--import-modules`). They are
406
+ * resolved from `tools/`, which `buildAgentServerEnv` exposes through
407
+ * OH_EXTRA_PYTHON_PATH. Importing `canvas_ui_tool` eagerly registers the SDK's
408
+ * builtin FinishTool so automation presets (openhands-automation >= 1.9.0) can
409
+ * resolve it on the remote conversations they dispatch β€” see the note at the
410
+ * bottom of tools/canvas_ui_tool.py.
411
+ */
412
+ export const AGENT_SERVER_IMPORT_MODULES = "canvas_ui_tool";
413
+
414
+ /**
415
+ * Build the uvx command and arguments for running agent-server.
416
+ *
417
+ * Environment variables (highest precedence first):
418
+ * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout.
419
+ * Runs the local checkout via uvx with editable installs of the workspace
420
+ * packages (openhands-sdk, openhands-tools, openhands-workspace) so source
421
+ * edits are picked up without a manual reinstall. The agent-server itself
422
+ * is rebuilt from local source on each invocation (--reinstall).
423
+ * - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
424
+ * - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.46.0")
425
+ *
426
+ * If none are set, defaults to the released version specified by
427
+ * DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
428
+ * git branch or commit instead.
429
+ *
430
+ * @param {Record<string, string | undefined>} env
431
+ * @returns {{ command: string, args: string[], source: string }}
432
+ */
433
+ export function buildAgentServerCommand(env = process.env) {
434
+ const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
435
+ const gitRef = env.OH_AGENT_SERVER_GIT_REF;
436
+ const version = env.OH_AGENT_SERVER_VERSION;
437
+
438
+ const uvxArgs = [];
439
+ let source = "";
440
+
441
+ if (localPath) {
442
+ if (!path.isAbsolute(localPath)) {
443
+ throw new Error(
444
+ `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
445
+ );
446
+ }
447
+ uvxArgs.push(
448
+ "--reinstall",
449
+ "--from",
450
+ path.join(localPath, "openhands-agent-server"),
451
+ "--with-editable",
452
+ path.join(localPath, "openhands-sdk"),
453
+ "--with-editable",
454
+ path.join(localPath, "openhands-tools"),
455
+ "--with-editable",
456
+ path.join(localPath, "openhands-workspace"),
457
+ "--with",
458
+ AGENT_SERVER_POSTHOG_CONSTRAINT,
459
+ "agent-server",
460
+ );
461
+ source = `local (${localPath})`;
462
+ } else if (gitRef) {
463
+ // Use git ref with subdirectory syntax for uv workspace monorepo.
464
+ // The software-agent-sdk repo has packages in subdirectories:
465
+ // openhands-agent-server/, openhands-sdk/, openhands-tools/, openhands-workspace/
466
+ // All four must come from the same ref so inter-package APIs stay in sync.
467
+ //
468
+ // --reinstall is required because the git branch may carry the same version
469
+ // string as the current PyPI release (e.g. both "1.26.0"). Without it, uv
470
+ // silently reuses the cached PyPI wheels and the git ref is never actually
471
+ // used, even though it was explicitly requested.
472
+ const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`;
473
+ uvxArgs.push(
474
+ "--reinstall",
475
+ "--from",
476
+ `${baseGitUrl}#subdirectory=openhands-agent-server`,
477
+ "--with",
478
+ `${baseGitUrl}#subdirectory=openhands-sdk`,
479
+ "--with",
480
+ `${baseGitUrl}#subdirectory=openhands-tools`,
481
+ "--with",
482
+ `${baseGitUrl}#subdirectory=openhands-workspace`,
483
+ "--with",
484
+ AGENT_SERVER_POSTHOG_CONSTRAINT,
485
+ "agent-server",
486
+ );
487
+ source = `git (${gitRef})`;
488
+ } else if (version) {
489
+ // Use specific PyPI version: uvx --from openhands-agent-server==version agent-server
490
+ // The package name differs from the executable name, so we need --from syntax
491
+ // Pin all SDK packages to the same version for consistency
492
+ uvxArgs.push(
493
+ "--from",
494
+ `${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`,
495
+ "--with",
496
+ `openhands-sdk==${version}`,
497
+ "--with",
498
+ `openhands-tools==${version}`,
499
+ "--with",
500
+ `openhands-workspace==${version}`,
501
+ );
502
+ if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) {
503
+ uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT);
504
+ }
505
+ uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT);
506
+ uvxArgs.push("agent-server");
507
+ source = `PyPI (${version})`;
508
+ } else {
509
+ // Default to released PyPI version
510
+ // Pin all SDK packages to the same version for consistency
511
+ uvxArgs.push(
512
+ "--from",
513
+ `${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`,
514
+ "--with",
515
+ `openhands-sdk==${DEFAULT_AGENT_SERVER_VERSION}`,
516
+ "--with",
517
+ `openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`,
518
+ "--with",
519
+ `openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`,
520
+ );
521
+ if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) {
522
+ uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT);
523
+ }
524
+ uvxArgs.push("--with", AGENT_SERVER_POSTHOG_CONSTRAINT);
525
+ uvxArgs.push("agent-server");
526
+ source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`;
527
+ }
528
+
529
+ // Everything after the executable name is an agent-server CLI argument.
530
+ // Import the registration module before any conversation is created.
531
+ uvxArgs.push("--import-modules", AGENT_SERVER_IMPORT_MODULES);
532
+
533
+ return {
534
+ command: "uvx",
535
+ args: uvxArgs,
536
+ source,
537
+ };
538
+ }
539
+
540
+ function parsePort(value, fallback) {
541
+ if (value == null || value === "") {
542
+ return fallback;
543
+ }
544
+
545
+ const parsed = Number.parseInt(value, 10);
546
+ if (!Number.isInteger(parsed) || parsed <= 0) {
547
+ throw new Error(`Invalid port: ${value}`);
548
+ }
549
+
550
+ return parsed;
551
+ }
552
+
553
+ /**
554
+ * Build safe dev configuration (synchronous version).
555
+ *
556
+ * Uses the port values from environment variables or defaults WITHOUT checking
557
+ * port availability. Use this when:
558
+ * - You need synchronous config (e.g., for test setup, config inspection)
559
+ * - Ports are already known to be available (e.g., specified via env vars)
560
+ * - You're building config objects for downstream use, not starting services
561
+ *
562
+ * For scripts that actually start services (dev-safe.mjs main, dev-with-automation.mjs),
563
+ * use {@link buildSafeDevConfigAsync} instead to handle port conflicts gracefully.
564
+ *
565
+ * @param {string} cwd - Current working directory
566
+ * @param {Record<string, string | undefined>} env - Environment variables
567
+ * @returns {SafeDevConfig} Configuration object
568
+ */
569
+ export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) {
570
+ const backendPort = parsePort(
571
+ env.OH_CANVAS_SAFE_BACKEND_PORT,
572
+ DEFAULT_BACKEND_PORT,
573
+ );
574
+ const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1);
575
+
576
+ return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env);
577
+ }
578
+
579
+ /**
580
+ * Build safe dev configuration with dynamic port allocation.
581
+ *
582
+ * Tries preferred ports first; if busy, finds available alternatives.
583
+ * This is the recommended entry point for scripts that start services.
584
+ *
585
+ * @param {string} cwd - Current working directory
586
+ * @param {Record<string, string | undefined>} env - Environment variables
587
+ * @returns {Promise<SafeDevConfig>} Configuration object with allocated ports
588
+ */
589
+ export async function buildSafeDevConfigAsync(
590
+ cwd = process.cwd(),
591
+ env = process.env,
592
+ ) {
593
+ // Get preferred ports from env or defaults
594
+ const preferredBackendPort = parsePort(
595
+ env.OH_CANVAS_SAFE_BACKEND_PORT,
596
+ DEFAULT_BACKEND_PORT,
597
+ );
598
+ const preferredVscodePort = parsePort(
599
+ env.OH_CANVAS_SAFE_VSCODE_PORT,
600
+ preferredBackendPort + 1,
601
+ );
602
+
603
+ // Fail fast if any required port is already in use.
604
+ await assertPortsFree([
605
+ { name: "agent-server", port: preferredBackendPort },
606
+ { name: "vscode", port: preferredVscodePort },
607
+ ]);
608
+
609
+ return buildConfigFromPorts(
610
+ { backendPort: preferredBackendPort, vscodePort: preferredVscodePort },
611
+ cwd,
612
+ env,
613
+ );
614
+ }
615
+
616
+ /**
617
+ * @typedef {object} SafeDevConfig
618
+ * @property {string} cwd
619
+ * @property {number} backendPort
620
+ * @property {number} vscodePort
621
+ * @property {string} vscodeBasePath
622
+ * @property {string} stateDir
623
+ * @property {string} tmuxTmpDir
624
+ * @property {string} conversationsPath
625
+ * @property {string} workspacesPath
626
+ * @property {string} bashEventsDir
627
+ * @property {string} backendBaseUrl
628
+ * @property {string} backendHost
629
+ * @property {string} workingDir
630
+ * @property {string} secretKey
631
+ * @property {string} sessionApiKey
632
+ * @property {string} canvasToolsDir
633
+ */
634
+
635
+ /**
636
+ * Internal helper to build config from already-resolved ports.
637
+ * @param {{backendPort: number, vscodePort: number}} ports
638
+ * @param {string} cwd
639
+ * @param {Record<string, string | undefined>} env
640
+ * @returns {SafeDevConfig}
641
+ */
642
+ function buildConfigFromPorts(ports, cwd, env) {
643
+ const { backendPort, vscodePort } = ports;
644
+ const stateDir = path.resolve(
645
+ cwd,
646
+ env.OH_CANVAS_SAFE_STATE_DIR ||
647
+ path.join(homedir(), ".openhands", "agent-canvas"),
648
+ );
649
+ const conversationsPath = path.join(stateDir, "dev_conversations");
650
+ const workspacesPath = path.join(stateDir, "workspaces");
651
+ // Use provided secret key, or read/generate one persisted to
652
+ // ~/.openhands/agent-canvas/secret-key.txt. Persisting ensures dev mode
653
+ // and Docker mode share the same encryption key when they mount the same
654
+ // ~/.openhands directory (docker/entrypoint.sh reads/writes the same file).
655
+ const secretKeyPath = env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH;
656
+ const secretKey =
657
+ env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret");
658
+ // Use the user-provided LOCAL_BACKEND_API_KEY or fall back to a key
659
+ // persisted to ~/.openhands/agent-canvas/api-key.txt. Persisting on disk
660
+ // keeps the agent-server, the Vite-baked VITE_SESSION_API_KEY, and any
661
+ // `openhands-backends` localStorage entries the frontend has cached all
662
+ // pointing at the same value across dev restarts.
663
+ //
664
+ // LOCAL_BACKEND_API_KEY is the single user-facing env var for the API key.
665
+ // OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests).
666
+ const persistedKeyPath = env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH;
667
+ const sessionApiKey =
668
+ env.LOCAL_BACKEND_API_KEY ||
669
+ getOrCreatePersistedApiKeyFile(persistedKeyPath);
670
+
671
+ // Host directory containing the legacy canvas_ui Python module. Persisted
672
+ // conversations created before the client_tools migration still reference
673
+ // its module qualname, so the agent-server can import it when resuming them.
674
+ const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url));
675
+
676
+ return {
677
+ cwd,
678
+ backendPort,
679
+ vscodePort,
680
+ vscodeBasePath: VSCODE_BASE_PATH,
681
+ stateDir,
682
+ // tmux socket directory. Defaults to <stateDir>/tmux (under
683
+ // ~/.openhands/agent-canvas), matching where the rest of dev state lives
684
+ // and persisting across restarts.
685
+ //
686
+ // Do NOT use os.tmpdir() here: on macOS it resolves to the per-user
687
+ // $TMPDIR (/var/folders/.../T), which the OS periodically reaps
688
+ // (com.apple.bsd.dirhelper deletes entries untouched for a few days).
689
+ // Reaping deletes the live tmux socket while the server process keeps
690
+ // running, orphaning it β€” every later new-window then fails with
691
+ // "error connecting to .../openhands (No such file or directory)".
692
+ //
693
+ // The only hosts where <stateDir>/tmux can't hold the socket are those
694
+ // whose $HOME is a network/overlay mount without Unix-domain-socket
695
+ // support (some devcontainers, NFS/CIFS homes). Those rare cases can point
696
+ // tmux at a local, socket-capable path with the standard TMUX_TMPDIR env
697
+ // var (e.g. TMUX_TMPDIR=/tmp), which we honor and pass through below.
698
+ tmuxTmpDir: env.TMUX_TMPDIR || path.join(stateDir, "tmux"),
699
+ conversationsPath,
700
+ workspacesPath,
701
+ bashEventsDir: path.join(stateDir, "bash_events"),
702
+ backendBaseUrl: `http://127.0.0.1:${backendPort}`,
703
+ backendHost: `127.0.0.1:${backendPort}`,
704
+ workingDir: env.VITE_WORKING_DIR || workspacesPath,
705
+ secretKey,
706
+ sessionApiKey,
707
+ canvasToolsDir,
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Telemetry-related env vars for the agent-server process.
713
+ *
714
+ * Split out from `buildAgentServerEnv` so callers that assemble their own
715
+ * agent-server environment can reuse the same mapping.
716
+ *
717
+ * @param {Record<string, string | undefined>} [env] - Source environment.
718
+ * @returns {Record<string, string>} Telemetry env vars for agent-server
719
+ */
720
+ export function buildAgentServerTelemetryEnv(env = process.env) {
721
+ const telemetryDisabled =
722
+ env.VITE_DO_NOT_TRACK === "1" || env.DO_NOT_TRACK === "1";
723
+ const result = {};
724
+
725
+ for (const key of [
726
+ "OH_TELEMETRY_EXPORTER",
727
+ "OH_TELEMETRY_POSTHOG_API_KEY",
728
+ "OH_TELEMETRY_POSTHOG_HOST",
729
+ "OH_TELEMETRY_HTTP_ENDPOINT",
730
+ "OH_TELEMETRY_HTTP_TOKEN",
731
+ "OH_TELEMETRY_CONSENT",
732
+ "OH_TELEMETRY_CONSENT_MODE",
733
+ "OH_TELEMETRY_SALT",
734
+ ]) {
735
+ if (env[key]) result[key] = env[key];
736
+ }
737
+
738
+ if (telemetryDisabled) {
739
+ result.DO_NOT_TRACK = "1";
740
+ }
741
+
742
+ const apiKey =
743
+ env.OH_TELEMETRY_POSTHOG_API_KEY ||
744
+ env.VITE_POSTHOG_API_KEY ||
745
+ (telemetryDisabled ? "" : DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_API_KEY);
746
+ const exporter = env.OH_TELEMETRY_EXPORTER || (apiKey ? "posthog" : "");
747
+
748
+ if (exporter) {
749
+ result.OH_TELEMETRY_EXPORTER = exporter;
750
+ }
751
+
752
+ if (exporter === "posthog" && apiKey) {
753
+ result.OH_TELEMETRY_POSTHOG_API_KEY = apiKey;
754
+ result.OH_TELEMETRY_POSTHOG_HOST =
755
+ env.OH_TELEMETRY_POSTHOG_HOST ||
756
+ env.VITE_POSTHOG_HOST ||
757
+ DEFAULT_AGENT_SERVER_TELEMETRY_POSTHOG_HOST;
758
+ }
759
+
760
+ return result;
761
+ }
762
+
763
+ /**
764
+ * Build the environment variables object for spawning the agent-server process.
765
+ *
766
+ * This is exported so downstream consumers (e.g., automation service) can use
767
+ * the same env vars without duplicating the mapping logic.
768
+ *
769
+ * `vscodeBasePath` is an explicit opt-in rather than a field read off `config`,
770
+ * and that is deliberate. Setting it changes the URL `/api/vscode/url`
771
+ * advertises: agent-server appends the prefix to the browser origin the
772
+ * frontend sends, so the editor is only reachable if the same origin also
773
+ * routes that prefix to the editor port. A launcher that sets it without
774
+ * registering the route advertises `<origin>/vscode/…`, which serves the
775
+ * canvas SPA shell instead of the editor.
776
+ *
777
+ * Requiring the caller to name it makes the pairing greppable: every call site
778
+ * that passes `vscodeBasePath` must also register a matching route, and
779
+ * `__tests__/scripts/vscode-base-path-opt-in.test.ts` asserts that no launcher
780
+ * opts in without one.
781
+ *
782
+ * @param {ReturnType<typeof buildSafeDevConfig>} config - Config from buildSafeDevConfig
783
+ * @param {{vscodeBasePath?: string | null, env?: Record<string, string | undefined>}} [options]
784
+ * @param {string | null} [options.vscodeBasePath] - Opt into prefix-mode by
785
+ * passing the path prefix the caller also routes to `config.vscodePort`.
786
+ * @param {Record<string, string | undefined>} [options.env] - Source
787
+ * environment for the telemetry mapping (defaults to `process.env`).
788
+ * @returns {Record<string, string>} Environment variables for agent-server
789
+ */
790
+ export function buildAgentServerEnv(config, options = {}) {
791
+ const { vscodeBasePath = null, env = process.env } = options;
792
+ return {
793
+ ...buildAgentServerTelemetryEnv(env),
794
+ // Force Python to use UTF-8 for all file I/O and streams.
795
+ //
796
+ // On Windows, Python defaults to the system ANSI codepage (e.g. cp1252).
797
+ // The agent-server writes conversation metadata JSON that can contain
798
+ // emoji (e.g. βœ… U+2705) which cp1252 cannot encode, producing:
799
+ // UnicodeEncodeError: 'charmap' codec can't encode character '\u2705'
800
+ // Setting PYTHONUTF8=1 enables Python's UTF-8 mode (PEP 540) for the
801
+ // entire agent-server process, matching the behaviour on Linux/macOS
802
+ // where the locale is already UTF-8.
803
+ // This is a no-op on Linux/macOS where the locale is already UTF-8.
804
+ PYTHONUTF8: "1",
805
+ TMUX_TMPDIR: config.tmuxTmpDir,
806
+ // Parent of stateDir (= ~/.openhands) so settings/secrets match Docker.
807
+ OH_PERSISTENCE_DIR: path.dirname(config.stateDir),
808
+ OH_CONVERSATIONS_PATH: config.conversationsPath,
809
+ OH_BASH_EVENTS_DIR: config.bashEventsDir,
810
+ OH_VSCODE_PORT: String(config.vscodePort),
811
+ // Serve the editor under a path prefix on the canvas origin rather than on
812
+ // its own published port. agent-server passes this to openvscode-server as
813
+ // --server-base-path and includes it in the URL from /api/vscode/url, which
814
+ // matches the ingress route the caller registers for the same prefix.
815
+ //
816
+ // Omitted unless the caller opts in β€” see the note on this function.
817
+ ...(vscodeBasePath ? { OH_VSCODE_BASE_PATH: vscodeBasePath } : {}),
818
+ OH_SECRET_KEY: config.secretKey,
819
+ // Use OH_SESSION_API_KEYS_0 for agent-server V1 config format
820
+ OH_SESSION_API_KEYS_0: config.sessionApiKey,
821
+ // Alias for the agent-server's own URL. The agent-server itself sets
822
+ // OH_INTERNAL_SERVER_URL at startup, but downstream consumers (the
823
+ // OpenHands SDK boilerplate emitted by automation prompt/plugin
824
+ // presets) read AGENT_SERVER_URL β€” the canonical SDK name. Mirror it
825
+ // here so automation runs work without each tarball having to know
826
+ // about the OH_-prefixed variant.
827
+ //
828
+ // We deliberately do NOT set a SESSION_API_KEY alias: the SDK's
829
+ // sanitized_env() would strip it from bash subprocesses anyway, and
830
+ // a follow-up change to the automation preset reads
831
+ // OH_SESSION_API_KEYS_0 directly (which is already in env).
832
+ AGENT_SERVER_URL: config.backendBaseUrl,
833
+ // Let the agent-server resolve canvas_ui_tool when old persisted metadata
834
+ // requests that compatibility module during startup.
835
+ OH_EXTRA_PYTHON_PATH: config.canvasToolsDir,
836
+ };
837
+ }
838
+
839
+ // Re-export so existing importers (dev-with-automation.mjs, tests) keep
840
+ // resolving `buildRuntimeServicesInfo` from this module. The implementation
841
+ // now lives in ./runtime-services-info.mjs (imported at the top of this file).
842
+ export { buildRuntimeServicesInfo };
843
+
844
+ export function buildNpmScriptCommand(
845
+ scriptName,
846
+ platform = process.platform,
847
+ env = process.env,
848
+ nodeExecPath = process.execPath,
849
+ ) {
850
+ // On Windows, always use cmd.exe regardless of whether npm_execpath is set.
851
+ // npm_execpath points to a path like
852
+ // "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" which contains
853
+ // spaces. When that path is passed as an argument with shell:true in
854
+ // spawnService, cmd.exe splits on the space and tries to run "C:\Program"
855
+ // as a command, producing "not recognized as an internal or external command".
856
+ // Using "npm" via cmd.exe avoids the problem entirely.
857
+ if (platform === "win32") {
858
+ return {
859
+ command: env.ComSpec || "cmd.exe",
860
+ args: ["/d", "/s", "/c", "npm", "run", scriptName],
861
+ };
862
+ }
863
+
864
+ if (env.npm_execpath) {
865
+ return {
866
+ command: env.npm_node_execpath || nodeExecPath,
867
+ args: [env.npm_execpath, "run", scriptName],
868
+ };
869
+ }
870
+
871
+ return {
872
+ command: "npm",
873
+ args: ["run", scriptName],
874
+ };
875
+ }
876
+
877
+ export function validateLocalAgentServerPath(localPath) {
878
+ if (!path.isAbsolute(localPath)) {
879
+ throw new Error(
880
+ `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
881
+ );
882
+ }
883
+ if (!existsSync(localPath)) {
884
+ throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`);
885
+ }
886
+ for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) {
887
+ const subdirPath = path.join(localPath, subdir);
888
+ if (!existsSync(subdirPath)) {
889
+ throw new Error(
890
+ `OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`,
891
+ );
892
+ }
893
+ }
894
+ }
895
+
896
+ async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
897
+ const startedAt = Date.now();
898
+
899
+ while (Date.now() - startedAt < timeoutMs) {
900
+ try {
901
+ const response = await fetch(url);
902
+ if (response.ok) {
903
+ return;
904
+ }
905
+ } catch {
906
+ // Keep polling until timeout.
907
+ }
908
+
909
+ await delay(500);
910
+ }
911
+
912
+ throw new Error(`Timed out waiting for agent-server at ${url}`);
913
+ }
914
+
915
+ function spawnProcess(command, args, options = {}) {
916
+ const child = spawn(
917
+ command,
918
+ args,
919
+ getProcessTreeSpawnOptions({
920
+ stdio: "inherit",
921
+ ...options,
922
+ }),
923
+ );
924
+
925
+ child.once("error", (error) => {
926
+ if (isEnoentError(error) && command === "uvx") {
927
+ const msg = formatMissingUvxGuidance(options?.cwd);
928
+ console.error(msg);
929
+ fileLog("error", stripAnsi(msg));
930
+ } else if (isEnoentError(error)) {
931
+ const msg = `Failed to start ${command}. Make sure it is installed and on your PATH.`;
932
+ console.error(msg);
933
+ fileLog("error", msg);
934
+ } else {
935
+ console.error(`Failed to start ${command}:`, error);
936
+ fileLog("error", `Failed to start ${command}: ${error.message}`);
937
+ }
938
+ });
939
+
940
+ return child;
941
+ }
942
+
943
+ async function main() {
944
+ console.log("Starting isolated agent-server + frontend dev stack...");
945
+ fileLog("info", "Starting isolated agent-server + frontend dev stack...");
946
+ validateFrontendDependencies();
947
+ console.log("Frontend dependencies found.");
948
+ fileLog("info", "Frontend dependencies found.");
949
+ console.log("Allocating ports...");
950
+ fileLog("info", "Allocating ports...");
951
+
952
+ // Use async config builder with dynamic port allocation
953
+ const config = await buildSafeDevConfigAsync();
954
+
955
+ if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
956
+ validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
957
+ }
958
+
959
+ for (const dir of [
960
+ config.stateDir,
961
+ config.tmuxTmpDir,
962
+ config.conversationsPath,
963
+ config.workspacesPath,
964
+ config.bashEventsDir,
965
+ ]) {
966
+ mkdirSync(dir, { recursive: true });
967
+ }
968
+
969
+ const agentServerCmd = buildAgentServerCommand();
970
+
971
+ const secretKeySource = process.env.OH_SECRET_KEY
972
+ ? "custom (from OH_SECRET_KEY)"
973
+ : `persisted (${process.env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH})`;
974
+
975
+ const sessionKeySource = process.env.LOCAL_BACKEND_API_KEY
976
+ ? "custom (from LOCAL_BACKEND_API_KEY)"
977
+ : `persisted (${
978
+ process.env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH
979
+ })`;
980
+
981
+ console.log(`- agent-server: ${agentServerCmd.source}`);
982
+ console.log(`- backend: ${config.backendBaseUrl}`);
983
+ console.log(`- vscode port: ${config.vscodePort}`);
984
+ console.log(`- working dir: ${config.workingDir}`);
985
+ console.log(`- isolated state dir: ${config.stateDir}`);
986
+ console.log(`- secret key: ${secretKeySource}`);
987
+ console.log(`- session API key: ${sessionKeySource}`);
988
+ console.log("");
989
+ fileLog(
990
+ "info",
991
+ [
992
+ "Agent-server stack config:",
993
+ ` agent-server: ${agentServerCmd.source}`,
994
+ ` backend: ${config.backendBaseUrl}`,
995
+ ` working dir: ${config.workingDir}`,
996
+ ` state dir: ${config.stateDir}`,
997
+ ].join("\n"),
998
+ );
999
+
1000
+ const backend = spawnProcess(
1001
+ agentServerCmd.command,
1002
+ [
1003
+ ...agentServerCmd.args,
1004
+ "--host",
1005
+ "127.0.0.1",
1006
+ "--port",
1007
+ String(config.backendPort),
1008
+ ],
1009
+ {
1010
+ cwd: config.cwd,
1011
+ env: {
1012
+ ...process.env,
1013
+ // Opt into prefix-mode: the Vite dev server proxies the same prefix to
1014
+ // `config.vscodePort` (see VITE_VSCODE_TARGET below), so the advertised
1015
+ // URL resolves on the frontend origin the browser is actually on.
1016
+ ...buildAgentServerEnv(config, {
1017
+ vscodeBasePath: config.vscodeBasePath,
1018
+ }),
1019
+ },
1020
+ },
1021
+ );
1022
+
1023
+ let shuttingDown = false;
1024
+ let frontend = null;
1025
+
1026
+ const shutdown = (signal = "SIGTERM") => {
1027
+ if (shuttingDown) {
1028
+ return;
1029
+ }
1030
+
1031
+ shuttingDown = true;
1032
+ if (frontend) {
1033
+ signalProcessTree(frontend, signal);
1034
+ }
1035
+ signalProcessTree(backend, signal);
1036
+
1037
+ setTimeout(() => {
1038
+ if (frontend && isProcessRunning(frontend)) {
1039
+ signalProcessTree(frontend, "SIGKILL");
1040
+ }
1041
+ if (isProcessRunning(backend)) {
1042
+ signalProcessTree(backend, "SIGKILL");
1043
+ }
1044
+ process.exit(process.exitCode ?? 0);
1045
+ }, 3000);
1046
+ };
1047
+
1048
+ process.on("SIGINT", () => shutdown("SIGINT"));
1049
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1050
+ // Services are spawned detached, so a SIGHUP that kills this launcher (terminal
1051
+ // or multiplexer death) would otherwise leave the whole tree running. Forward
1052
+ // SIGTERM rather than SIGHUP: uvicorn only handles SIGINT/SIGTERM, so a
1053
+ // forwarded SIGHUP would terminate the agent-server by default action instead
1054
+ // of shutting it down gracefully.
1055
+ process.on("SIGHUP", () => shutdown("SIGTERM"));
1056
+
1057
+ const backendErrored = new Promise((_, reject) => {
1058
+ backend.once("error", (error) => reject(error));
1059
+ });
1060
+ const backendExited = new Promise((_, reject) => {
1061
+ backend.once("exit", (code, signal) => {
1062
+ if (!shuttingDown) {
1063
+ reject(
1064
+ new Error(
1065
+ `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
1066
+ ),
1067
+ );
1068
+ }
1069
+ });
1070
+ });
1071
+
1072
+ try {
1073
+ await Promise.race([
1074
+ waitForServer(`${config.backendBaseUrl}/server_info`),
1075
+ backendErrored,
1076
+ backendExited,
1077
+ ]);
1078
+ } catch (error) {
1079
+ shutdown();
1080
+ throw error;
1081
+ }
1082
+
1083
+ const frontendCommand = buildNpmScriptCommand("dev:frontend");
1084
+ frontend = spawnProcess(frontendCommand.command, frontendCommand.args, {
1085
+ cwd: config.cwd,
1086
+ env: {
1087
+ ...process.env,
1088
+ VITE_BACKEND_HOST: config.backendHost,
1089
+ VITE_BACKEND_BASE_URL: config.backendBaseUrl,
1090
+ VITE_WORKING_DIR: config.workingDir,
1091
+ // Pass session API key so frontend can authenticate with agent-server
1092
+ VITE_SESSION_API_KEY: config.sessionApiKey,
1093
+ // This mode has no static server or ingress in front of Vite, so Vite's
1094
+ // own proxy is the only thing that can serve the editor prefix on the
1095
+ // frontend origin. The editor is a separate process on a port of its
1096
+ // own, so it needs its own proxy target rather than VITE_BACKEND_HOST.
1097
+ VITE_VSCODE_BASE_PATH: config.vscodeBasePath,
1098
+ VITE_VSCODE_TARGET: `http://127.0.0.1:${config.vscodePort}`,
1099
+ // dev:minimal deliberately does NOT supply runtime-services info (the
1100
+ // frontend here talks straight to the agent-server over
1101
+ // VITE_BACKEND_BASE_URL β€” there is no ingress or static-server in front
1102
+ // of it to append `runtime_services` to `/server_info`, and the
1103
+ // frontend's own VITE_RUNTIME_SERVICES_INFO env var is no longer read).
1104
+ // It is a bare agent-server + Vite stack with no companion services to
1105
+ // advertise, so `fetchBackendRuntimeServicesInfo()` correctly returns
1106
+ // null and conversations simply omit the <RUNTIME_SERVICES> block.
1107
+ // Stacks with automation/ingress/frontend services should use
1108
+ // `npm run dev` / `dev:static`, which pass runtime-services info through
1109
+ // ingress/static-server instead.
1110
+ },
1111
+ });
1112
+
1113
+ frontend.once("exit", (code) => {
1114
+ shutdown();
1115
+ process.exitCode = code ?? 0;
1116
+ });
1117
+
1118
+ backend.once("exit", (code) => {
1119
+ if (!shuttingDown) {
1120
+ const msg = `agent-server exited unexpectedly with code ${code ?? 0}`;
1121
+ console.error(msg);
1122
+ fileLog("error", msg);
1123
+ shutdown();
1124
+ process.exitCode = code ?? 1;
1125
+ }
1126
+ });
1127
+ }
1128
+
1129
+ // ─────────────────────────────────────────────────────────────────────────────
1130
+ // Conversation lease cleanup
1131
+ // ─────────────────────────────────────────────────────────────────────────────
1132
+
1133
+ /**
1134
+ * Returns true if `host:port` accepts a TCP connection within `timeoutMs`.
1135
+ * Used to detect a live agent-server we shouldn't disturb.
1136
+ */
1137
+ export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) {
1138
+ return new Promise((resolve) => {
1139
+ const socket = new net.Socket();
1140
+ let settled = false;
1141
+ const finish = (busy) => {
1142
+ if (settled) return;
1143
+ settled = true;
1144
+ socket.destroy();
1145
+ resolve(busy);
1146
+ };
1147
+ socket.setTimeout(timeoutMs);
1148
+ socket.once("connect", () => finish(true));
1149
+ socket.once("timeout", () => finish(false));
1150
+ socket.once("error", () => finish(false));
1151
+ socket.connect(port, host);
1152
+ });
1153
+ }
1154
+
1155
+ /**
1156
+ * Remove stale `owner_lease.json` files under `conversationsDir` so a
1157
+ * freshly spawned agent-server can claim ownership and re-load every
1158
+ * existing conversation.
1159
+ *
1160
+ * Why this is needed: each conversation directory carries an
1161
+ * `owner_lease.json` that locks it to a single agent-server's
1162
+ * `owner_instance_id` for a 45 s TTL refreshed by heartbeat. On
1163
+ * graceful shutdown the agent-server unlinks its leases; on a hard
1164
+ * kill (or a fast restart, well under 45 s) the leases linger. A new
1165
+ * agent-server with a fresh `owner_instance_id` will then raise
1166
+ * `ConversationLeaseHeldError` for each conversation at startup load
1167
+ * and skip it entirely β€” `/api/conversations/search` returns `[]`
1168
+ * even though the meta files are right there on disk.
1169
+ *
1170
+ * The caller MUST verify (e.g. with `isPortBusy`) that no agent-server
1171
+ * is currently bound to the backend port before calling this β€” there
1172
+ * is no other reliable way to tell a stale lease from an actively
1173
+ * renewed one.
1174
+ *
1175
+ * Returns the number of lease files unlinked.
1176
+ */
1177
+ export function releaseStaleConversationLeases(conversationsDir) {
1178
+ if (!existsSync(conversationsDir)) return 0;
1179
+
1180
+ let removed = 0;
1181
+ for (const name of readdirSync(conversationsDir)) {
1182
+ const convDir = path.join(conversationsDir, name);
1183
+ let isDir = false;
1184
+ try {
1185
+ isDir = statSync(convDir).isDirectory();
1186
+ } catch {
1187
+ continue;
1188
+ }
1189
+ if (!isDir) continue;
1190
+
1191
+ const leasePath = path.join(convDir, "owner_lease.json");
1192
+ if (!existsSync(leasePath)) continue;
1193
+ try {
1194
+ unlinkSync(leasePath);
1195
+ removed += 1;
1196
+ } catch {
1197
+ // Best-effort: the new agent-server will simply skip this
1198
+ // conversation as before. Don't fail the whole start.
1199
+ }
1200
+ }
1201
+ return removed;
1202
+ }
1203
+
1204
+ if (
1205
+ process.argv[1] &&
1206
+ import.meta.url === pathToFileURL(process.argv[1]).href
1207
+ ) {
1208
+ main().catch((error) => {
1209
+ const msg = error instanceof Error ? error.message : String(error);
1210
+ console.error(msg);
1211
+ fileLog("error", `Fatal error: ${msg}`);
1212
+ if (error instanceof Error && error.stack) {
1213
+ fileLog("error", error.stack);
1214
+ }
1215
+ process.exit(1);
1216
+ });
1217
+ }
scripts/dev-static.mjs ADDED
@@ -0,0 +1,665 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Static-frontend Development Stack
3
+ *
4
+ * Same as the default automation stack but serves a production build of the
5
+ * frontend via `scripts/static-server.mjs` instead of the Vite dev server.
6
+ * Designed for slow / flaky network situations (e.g. plane wifi)
7
+ * where Vite's ~1000 individual module requests per page load are the
8
+ * bottleneck. The static build collapses the frontend into ~50 hashed
9
+ * chunks that all 304 cleanly on reload.
10
+ *
11
+ * Architecture (identical to dev-with-automation, only the frontend differs):
12
+ * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
13
+ * β”‚ http://localhost:8000 (Ingress Proxy) β”‚
14
+ * β”‚ /api/automation/* β†’ Automation Backend β”‚
15
+ * β”‚ /api/*, /sockets β†’ Agent Server β”‚
16
+ * β”‚ /* β†’ Static Frontend β”‚
17
+ * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
18
+ * β”‚ β”‚ β”‚
19
+ * β–Ό β–Ό β–Ό
20
+ * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
21
+ * β”‚ sirv-cli β”‚ β”‚ Agent Server β”‚ β”‚ Automation β”‚
22
+ * β”‚ build/ β”‚ β”‚ (uvx) :18000 β”‚ β”‚ Backend (uvx) β”‚
23
+ * β”‚ :3001 β”‚ β”‚ β”‚ β”‚ :18001 β”‚
24
+ * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
25
+ *
26
+ * Usage:
27
+ * npm run dev:static
28
+ * npm run dev:static -- --port 12000
29
+ * npm run dev:static -- --skip-build # reuse an existing build/
30
+ * npm run dev:static -- --automation-ref feat/my-branch
31
+ *
32
+ * Environment variables (all optional, same as dev):
33
+ * - PORT: Ingress port (default: 8000)
34
+ * - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
35
+ * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
36
+ * - OH_SECRET_KEY: Session secret key
37
+ */
38
+
39
+ import { spawn, spawnSync } from "node:child_process";
40
+ import { join, resolve, dirname } from "node:path";
41
+ import { fileURLToPath, pathToFileURL } from "node:url";
42
+ import { setTimeout as delay } from "node:timers/promises";
43
+ import process from "node:process";
44
+
45
+ import { buildFrontend } from "./static-build.mjs";
46
+ import {
47
+ buildAgentServerCommand,
48
+ buildSafeDevConfig,
49
+ buildAgentServerEnv,
50
+ formatMissingUvxGuidance,
51
+ isPortBusy,
52
+ releaseStaleConversationLeases,
53
+ } from "./dev-safe.mjs";
54
+ import {
55
+ getProcessTreeSpawnOptions,
56
+ isProcessRunning,
57
+ resolveWindowsCommand,
58
+ signalProcessTree,
59
+ } from "./dev-process-utils.mjs";
60
+ import {
61
+ buildAgentServerAutomationEnv,
62
+ buildAutomationCommand,
63
+ buildAutomationTelemetryEnv,
64
+ buildAutomationRuntimeServicesInfo,
65
+ buildConfig,
66
+ buildRouteArgs,
67
+ getAgentServerBaseUrl,
68
+ getLocalServiceRoutes,
69
+ getNoReferrerPrefixArgs,
70
+ getVSCodeAdvertiseArgs,
71
+ } from "./dev-with-automation.mjs";
72
+
73
+ const __dirname = dirname(fileURLToPath(import.meta.url));
74
+ const projectRoot = resolve(__dirname, "..");
75
+
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+ // Terminal Styling
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+
80
+ const c = {
81
+ reset: "\x1b[0m",
82
+ bold: "\x1b[1m",
83
+ dim: "\x1b[2m",
84
+ red: "\x1b[31m",
85
+ green: "\x1b[32m",
86
+ yellow: "\x1b[33m",
87
+ blue: "\x1b[34m",
88
+ magenta: "\x1b[35m",
89
+ cyan: "\x1b[36m",
90
+ };
91
+
92
+ function logService(name, message, color = c.reset) {
93
+ const ts = new Date().toISOString().split("T")[1].split(".")[0];
94
+ console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
95
+ }
96
+
97
+ function logStep(step, message) {
98
+ console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
99
+ }
100
+
101
+ function logSuccess(message) {
102
+ console.log(`${c.green}βœ“${c.reset} ${message}`);
103
+ }
104
+
105
+ function logError(message) {
106
+ console.error(`${c.red}βœ—${c.reset} ${message}`);
107
+ }
108
+
109
+ // ═══════════════════════════════════════════════════════════════════════════
110
+ // CLI parsing
111
+ // ═══════════════════════════════════════════════════════════════════════════
112
+
113
+ export function parseArgs(argv = process.argv.slice(2)) {
114
+ const config = {
115
+ port: null,
116
+ automationGitRef: null,
117
+ automationRepo: null,
118
+ skipBuild: false,
119
+ verbose: false,
120
+ };
121
+
122
+ for (let i = 0; i < argv.length; i++) {
123
+ switch (argv[i]) {
124
+ case "-p":
125
+ case "--port":
126
+ config.port = parseInt(argv[++i], 10);
127
+ break;
128
+ case "--automation-ref":
129
+ config.automationGitRef = argv[++i];
130
+ break;
131
+ case "--automation-repo":
132
+ config.automationRepo = argv[++i];
133
+ break;
134
+ case "--skip-build":
135
+ config.skipBuild = true;
136
+ break;
137
+ case "-v":
138
+ case "--verbose":
139
+ config.verbose = true;
140
+ break;
141
+ case "-h":
142
+ case "--help":
143
+ showHelp();
144
+ process.exit(0);
145
+ }
146
+ }
147
+
148
+ return config;
149
+ }
150
+
151
+ function showHelp() {
152
+ console.log(`
153
+ Agent Canvas Static-frontend Development Stack
154
+
155
+ Runs the automation stack, but serves a production build of the
156
+ frontend via scripts/static-server.mjs. Use this when a remote or flaky network
157
+ makes Vite's per-module requests painful (e.g. ngrok or plane wifi).
158
+
159
+ USAGE:
160
+ npm run dev:static [-- options]
161
+
162
+ OPTIONS:
163
+ -p, --port <port> Ingress port (default: 8000)
164
+ --automation-ref <ref> Git ref for automation backend (default: main)
165
+ --automation-repo <url> Git repo URL for automation
166
+ --skip-build Reuse existing build/ directory (faster restart)
167
+ -v, --verbose Show detailed output
168
+ -h, --help Show this help
169
+
170
+ ENVIRONMENT VARIABLES:
171
+ PORT Alternative to --port
172
+ OH_AUTOMATION_GIT_REF Alternative to --automation-ref
173
+ OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK
174
+ OH_SECRET_KEY Secret key for sessions
175
+
176
+ ACCESS POINTS:
177
+ Main UI: http://localhost:PORT/
178
+ API Docs: http://localhost:PORT/api/automation/docs
179
+
180
+ NOTES:
181
+ β€’ The build is produced once at startup. Edit the source and rerun this
182
+ command (or rebuild with \`npm run build:app\`) to pick up changes.
183
+ β€’ The static server sends ETag headers, so reloads return 304s instead of
184
+ refetching content β€” much friendlier on slow links.
185
+ `);
186
+ }
187
+
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+ // Prerequisites & Setup
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+
192
+ function commandExists(cmd) {
193
+ const result =
194
+ process.platform === "win32"
195
+ ? spawnSync("where.exe", [cmd], { stdio: "pipe" })
196
+ : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
197
+
198
+ return result.status === 0;
199
+ }
200
+
201
+ function checkPrerequisites() {
202
+ logStep("1/3", "Checking prerequisites...");
203
+
204
+ if (!commandExists("uvx")) {
205
+ console.error(formatMissingUvxGuidance(projectRoot));
206
+ process.exit(1);
207
+ }
208
+ logSuccess("uvx found");
209
+
210
+ if (!commandExists("npm")) {
211
+ logError("npm is required but not found");
212
+ process.exit(1);
213
+ }
214
+ logSuccess("npm found");
215
+ }
216
+
217
+ // ═══════════════════════════════════════════════════════════════════════════
218
+ // Process Management
219
+ // ═══════════════════════════════════════════════════════════════════════════
220
+
221
+ const processes = new Map();
222
+ let shuttingDown = false;
223
+
224
+ function spawnService(name, command, args, options = {}) {
225
+ const proc = spawn(
226
+ resolveWindowsCommand(command),
227
+ args,
228
+ getProcessTreeSpawnOptions({
229
+ stdio: ["ignore", "pipe", "pipe"],
230
+ env: { ...process.env, ...options.env },
231
+ cwd: options.cwd,
232
+ }),
233
+ );
234
+
235
+ const color = options.color || c.reset;
236
+
237
+ proc.stdout.on("data", (data) => {
238
+ data
239
+ .toString()
240
+ .split("\n")
241
+ .filter(Boolean)
242
+ .forEach((line) => logService(name, line.trim(), color));
243
+ });
244
+
245
+ proc.stderr.on("data", (data) => {
246
+ data
247
+ .toString()
248
+ .split("\n")
249
+ .filter(Boolean)
250
+ .forEach((line) => logService(name, line.trim(), c.yellow));
251
+ });
252
+
253
+ proc.on("error", (error) => {
254
+ logError(`${name} failed to start: ${error.message}`);
255
+ });
256
+
257
+ proc.on("exit", (code) => {
258
+ if (code !== 0 && code !== null && !shuttingDown) {
259
+ logService(name, `Exited with code ${code}`, c.red);
260
+ }
261
+ processes.delete(name);
262
+ });
263
+
264
+ processes.set(name, proc);
265
+ return proc;
266
+ }
267
+
268
+ async function waitForService(name, url, timeoutMs = 30000) {
269
+ const start = Date.now();
270
+
271
+ while (Date.now() - start < timeoutMs) {
272
+ try {
273
+ const res = await fetch(url);
274
+ if (res.ok) {
275
+ logService(name, `Ready at ${url}`, c.green);
276
+ return true;
277
+ }
278
+ } catch {
279
+ // Keep trying
280
+ }
281
+ await delay(500);
282
+ }
283
+
284
+ logService(name, `Timeout waiting for ${url}`, c.red);
285
+ return false;
286
+ }
287
+
288
+ // ═══════════════════════════════════════════════════════════════════════════
289
+ // Service Starters (agent-server + automation are byte-for-byte the same as
290
+ // dev-with-automation; the only difference is the frontend service.)
291
+ // ═══════════════════════════════════════════════════════════════════════════
292
+
293
+ // The static server and the ingress proxy front the same local backends, so
294
+ // they share one route table β€” dev-with-automation's, rather than a second
295
+ // copy here. The copy this replaces claimed to stay identical to that table
296
+ // but nothing enforced it, and it had already drifted: the editor prefix was
297
+ // missing, so `/vscode` fell through to the SPA fallback and answered editor
298
+ // requests with the canvas shell.
299
+ //
300
+ // This mode always launches both local backends (it never runs frontend-only),
301
+ // so it asks for their routes unconditionally. Every target is IPv4 loopback:
302
+ // the backends bind to `0.0.0.0`, which only accepts IPv4, but localhost can
303
+ // resolve to ::1 first (notably on Windows).
304
+ function buildLocalServiceRouteArgs(config) {
305
+ return buildRouteArgs(
306
+ getLocalServiceRoutes({
307
+ ...config,
308
+ launchAgentServer: true,
309
+ launchAutomation: true,
310
+ }),
311
+ );
312
+ }
313
+
314
+ function startAgentServer(config) {
315
+ logService(
316
+ "agent-server",
317
+ `Starting on port ${config.agentServerPort}...`,
318
+ c.blue,
319
+ );
320
+
321
+ const agentServerCmd = buildAgentServerCommand(process.env);
322
+ logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
323
+
324
+ const safeConfig = buildSafeDevConfig(config.canvasPath, {
325
+ ...process.env,
326
+ OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
327
+ OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
328
+ OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
329
+ });
330
+
331
+ const agentServerEnv = {
332
+ // Opt into prefix-mode: both the static server and the ingress below build
333
+ // their route tables from `getLocalServiceRoutes`, which registers this
334
+ // same prefix against `config.vscodePort`.
335
+ ...buildAgentServerEnv(safeConfig, {
336
+ vscodeBasePath: config.vscodeBasePath,
337
+ }),
338
+ ...buildAgentServerAutomationEnv(config),
339
+ };
340
+
341
+ spawnService(
342
+ "agent-server",
343
+ agentServerCmd.command,
344
+ [
345
+ ...agentServerCmd.args,
346
+ "--host",
347
+ "0.0.0.0",
348
+ "--port",
349
+ String(config.agentServerPort),
350
+ ],
351
+ {
352
+ cwd: safeConfig.workspacesPath,
353
+ env: agentServerEnv,
354
+ color: c.blue,
355
+ },
356
+ );
357
+ }
358
+
359
+ function buildAutomationBackendEnv(config, env = process.env) {
360
+ // Both backends share the same session API key value.
361
+ return {
362
+ AUTOMATION_AGENT_SERVER_URL: getAgentServerBaseUrl(config),
363
+ AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
364
+ AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
365
+ AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`,
366
+ AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
367
+ AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
368
+ ...buildAutomationTelemetryEnv(env),
369
+ AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
370
+ FILE_STORE: "local",
371
+ LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
372
+ OPENHANDS_SUPPRESS_BANNER: "1",
373
+ };
374
+ }
375
+
376
+ function startAutomationBackend(config) {
377
+ logService(
378
+ "automation",
379
+ `Starting on port ${config.autoBackendPort}...`,
380
+ c.green,
381
+ );
382
+
383
+ const automationCmd = buildAutomationCommand(process.env);
384
+ logService("automation", `Using ${automationCmd.source}`, c.dim);
385
+
386
+ spawnService(
387
+ "automation",
388
+ automationCmd.command,
389
+ [
390
+ ...automationCmd.args,
391
+ "--host",
392
+ "0.0.0.0",
393
+ "--port",
394
+ config.autoBackendPort.toString(),
395
+ ],
396
+ {
397
+ cwd: config.stateDir,
398
+ env: buildAutomationBackendEnv(config),
399
+ color: c.green,
400
+ },
401
+ );
402
+ }
403
+
404
+ function startStaticServer(config) {
405
+ // Reuse `vitePort` as the upstream port name so the ingress route table
406
+ // below stays identical to dev-with-automation.mjs.
407
+ logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
408
+
409
+ // Mirror the proxy targets that vite.config.ts exposes in dev mode so that
410
+ // hitting :3001 directly behaves like Vite's dev server (e.g. /server_info
411
+ // is forwarded to the agent-server instead of falling back to the SPA
412
+ // shell). Without this, /server_info on :3001 returns index.html.
413
+ const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
414
+ const runtimeServicesInfo = JSON.stringify(
415
+ buildAutomationRuntimeServicesInfo({
416
+ ...config,
417
+ frontendKind: "static",
418
+ }),
419
+ );
420
+ spawnService(
421
+ "static",
422
+ "node",
423
+ [
424
+ staticServerScript,
425
+ "--dir",
426
+ join(config.canvasPath, "build"),
427
+ "--port",
428
+ String(config.vitePort),
429
+ ...(process.env.VITE_BASE_PATH
430
+ ? ["--base-path", process.env.VITE_BASE_PATH]
431
+ : []),
432
+ // Inject the API key so the pre-built frontend can authenticate
433
+ // to the agent-server without a baked-in VITE_SESSION_API_KEY.
434
+ ...(config.sessionApiKey
435
+ ? ["--session-api-key", config.sessionApiKey]
436
+ : []),
437
+ "--runtime-services-info",
438
+ runtimeServicesInfo,
439
+ ...buildLocalServiceRouteArgs(config),
440
+ // Only the static server injects into the document, so only it can tell
441
+ // the frontend this origin serves the editor. The ingress below routes
442
+ // the same prefix but proxies the HTML through untouched.
443
+ ...getVSCodeAdvertiseArgs(config),
444
+ ...getNoReferrerPrefixArgs(config),
445
+ ],
446
+ {
447
+ cwd: config.canvasPath,
448
+ color: c.magenta,
449
+ },
450
+ );
451
+ }
452
+
453
+ function startIngress(config) {
454
+ logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
455
+
456
+ const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
457
+ const runtimeServicesInfo = JSON.stringify(
458
+ buildAutomationRuntimeServicesInfo({
459
+ ...config,
460
+ frontendKind: "static",
461
+ }),
462
+ );
463
+
464
+ spawnService(
465
+ "ingress",
466
+ "node",
467
+ [
468
+ ingressScript,
469
+ "--port",
470
+ config.ingressPort.toString(),
471
+ "--runtime-services-info",
472
+ runtimeServicesInfo,
473
+ ...buildLocalServiceRouteArgs(config),
474
+ ...getNoReferrerPrefixArgs(config),
475
+ "--default",
476
+ `http://localhost:${config.vitePort}`,
477
+ ],
478
+ {
479
+ cwd: projectRoot,
480
+ color: c.yellow,
481
+ },
482
+ );
483
+ }
484
+
485
+ // ═══════════════════════════════════════════════════════════════════════════
486
+ // Shutdown / Banner
487
+ // ═══════════════════════════════════════════════════════════════════════════
488
+
489
+ function shutdown() {
490
+ if (shuttingDown) return;
491
+ shuttingDown = true;
492
+
493
+ console.log("");
494
+ console.log(`${c.yellow}Shutting down...${c.reset}`);
495
+
496
+ for (const [name, proc] of processes) {
497
+ logService(name, "Stopping...", c.dim);
498
+ signalProcessTree(proc, "SIGTERM");
499
+ }
500
+
501
+ setTimeout(() => {
502
+ for (const [name, proc] of processes) {
503
+ if (isProcessRunning(proc)) {
504
+ logService(name, "Force stopping...", c.dim);
505
+ signalProcessTree(proc, "SIGKILL");
506
+ }
507
+ }
508
+ process.exit(0);
509
+ }, 3000);
510
+ }
511
+
512
+ process.on("SIGINT", shutdown);
513
+ process.on("SIGTERM", shutdown);
514
+
515
+ function printBanner(config) {
516
+ console.log("");
517
+ console.log(
518
+ `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
519
+ );
520
+ console.log(
521
+ `${c.green}${c.bold}β•‘${c.reset} ${c.bold}Agent Canvas Static-frontend Stack${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
522
+ );
523
+ console.log(
524
+ `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
525
+ );
526
+ console.log(
527
+ `${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
528
+ );
529
+ console.log(
530
+ `${c.green}${c.bold}β•‘${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd(
531
+ 75,
532
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
533
+ );
534
+ console.log(
535
+ `${c.green}${c.bold}β•‘${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd(
536
+ 75,
537
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
538
+ );
539
+ console.log(
540
+ `${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
541
+ );
542
+ console.log(
543
+ `${c.green}${c.bold}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${c.reset}`,
544
+ );
545
+ console.log("");
546
+ console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
547
+ console.log(
548
+ `${c.dim}Frontend served from: ${join(config.canvasPath, "build")}${c.reset}`,
549
+ );
550
+ console.log(
551
+ `${c.dim}Edit sources, then re-run \`npm run dev:static\` to rebuild.${c.reset}`,
552
+ );
553
+ console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
554
+ console.log("");
555
+ }
556
+
557
+ // ═══════════════════════════════════════════════════════════════════════════
558
+ // Main
559
+ // ═══════════════════════════════════════════════════════════════════════════
560
+
561
+ async function main() {
562
+ const args = parseArgs();
563
+ const config = await buildConfig(args);
564
+
565
+ console.log("");
566
+ console.log(
567
+ `${c.cyan}${c.bold}Agent Canvas Static-frontend Development Stack${c.reset}`,
568
+ );
569
+ console.log("");
570
+
571
+ // Setup phase (1/3)
572
+ checkPrerequisites();
573
+
574
+ // Ensure isolated state dirs (same as dev-with-automation).
575
+ const { mkdirSync } = await import("node:fs");
576
+ for (const dir of [
577
+ config.stateDir,
578
+ join(config.stateDir, "dev_conversations"),
579
+ join(config.stateDir, "workspaces"),
580
+ join(config.stateDir, "bash_events"),
581
+ join(config.stateDir, "storage"),
582
+ ]) {
583
+ mkdirSync(dir, { recursive: true });
584
+ }
585
+
586
+ // Build phase (2/3): block until the SPA is ready to serve.
587
+ buildFrontend(config, args);
588
+
589
+ // Service phase (3/3)
590
+ logStep("3/3", "Starting services...");
591
+
592
+ // The agent-server skip-loads any conversation whose `owner_lease.json`
593
+ // is held by a different `owner_instance_id` and not yet expired (45 s
594
+ // TTL). If a previous agent-server (e.g. from `npm run dev`) was killed
595
+ // ungracefully β€” or we restart faster than the lease TTL β€” every
596
+ // conversation gets hidden until those stale leases age out, which
597
+ // looks like "the new agent-server doesn't inherit my conversations".
598
+ // Bail out if a live agent-server is already bound to our port (we'd
599
+ // collide anyway), otherwise unlink the stale leases so the new server
600
+ // can claim ownership immediately.
601
+ if (await isPortBusy(config.agentServerPort)) {
602
+ logError(
603
+ `Port ${config.agentServerPort} is already in use β€” another ` +
604
+ `agent-server is running. Stop it (e.g. quit \`npm run dev\`) ` +
605
+ `before running dev:static.`,
606
+ );
607
+ process.exit(1);
608
+ }
609
+ const conversationsPath = join(config.stateDir, "dev_conversations");
610
+ const cleared = releaseStaleConversationLeases(conversationsPath);
611
+ if (cleared > 0) {
612
+ logService(
613
+ "agent-server",
614
+ `Released ${cleared} stale conversation lease(s) so the new ` +
615
+ `agent-server can resume ownership.`,
616
+ c.dim,
617
+ );
618
+ }
619
+
620
+ startAgentServer(config);
621
+ await waitForService(
622
+ "agent-server",
623
+ `${getAgentServerBaseUrl(config)}/server_info`,
624
+ );
625
+
626
+ startAutomationBackend(config);
627
+
628
+ startStaticServer(config);
629
+
630
+ await delay(2000);
631
+
632
+ startIngress(config);
633
+
634
+ await delay(1000);
635
+
636
+ printBanner(config);
637
+ }
638
+
639
+ // ═══════════════════════════════════════════════════════════════════════════
640
+ // Exports for testing
641
+ // ═══════════════════════════════════════════════════════════════════════════
642
+
643
+ export {
644
+ buildAutomationBackendEnv,
645
+ buildFrontend,
646
+ buildLocalServiceRouteArgs,
647
+ startStaticServer,
648
+ };
649
+
650
+ // ═══════════════════════════════════════════════════════════════════════════
651
+ // Main entry point (only when run directly, not when imported)
652
+ // ═══════════════════════════════════════════════════════════════════════════
653
+
654
+ const isMainModule =
655
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
656
+
657
+ if (isMainModule) {
658
+ main().catch((err) => {
659
+ logError(`Fatal error: ${err.message}`);
660
+ if (err.stack) {
661
+ console.error(c.dim + err.stack + c.reset);
662
+ }
663
+ process.exit(1);
664
+ });
665
+ }
scripts/dev-with-automation.mjs ADDED
@@ -0,0 +1,1715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Development Stack with Automation Service
3
+ *
4
+ * Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
5
+ * backend via uvx. No cloning required - runs directly from git reference.
6
+ *
7
+ * Uses a standalone ingress proxy to route traffic to multiple backends.
8
+ *
9
+ * Architecture:
10
+ * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
11
+ * β”‚ http://localhost:8000 (Ingress Proxy) β”‚
12
+ * β”‚ /api/automation/* β†’ Automation Backend β”‚
13
+ * β”‚ /api/*, /sockets β†’ Agent Server β”‚
14
+ * β”‚ /* β†’ Vite Dev Server β”‚
15
+ * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
16
+ * β”‚ β”‚ β”‚
17
+ * β–Ό β–Ό β–Ό
18
+ * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
19
+ * β”‚ Vite β”‚ β”‚ Agent Server β”‚ β”‚ Automation β”‚
20
+ * β”‚ :3001 β”‚ β”‚ (uvx) :18000 β”‚ β”‚ Backend (uvx) β”‚
21
+ * β”‚ β”‚ β”‚ β”‚ β”‚ :18001 β”‚
22
+ * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
23
+ *
24
+ * Usage:
25
+ * node scripts/dev-with-automation.mjs
26
+ * node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
27
+ * node scripts/dev-with-automation.mjs --port 12000
28
+ *
29
+ * Environment variables:
30
+ * - PORT: Ingress port (default: 8000)
31
+ * - OH_AUTOMATION_GIT_REF: Git ref for automation (overrides default version)
32
+ * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk
33
+ * checkout. Highest precedence for agent-server source selection: rebuilds
34
+ * the agent-server from local source and installs openhands-sdk,
35
+ * openhands-tools and openhands-workspace as editable so source edits are
36
+ * picked up without manual reinstall.
37
+ * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
38
+ * Secrets:
39
+ * The session API key is automatically seeded into agent-server secrets
40
+ * as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
41
+ * Both the agent-server and automation backend use the same key value
42
+ * and the same `X-Session-API-Key` header for authentication.
43
+ * AUTOMATION_KV_SECRET is derived from the session key if not set explicitly,
44
+ * enabling the KV store out of the box for local development.
45
+ */
46
+
47
+ import { spawn, spawnSync } from "node:child_process";
48
+ import { mkdirSync, existsSync, readFileSync } from "node:fs";
49
+ import { join, resolve, dirname, isAbsolute } from "node:path";
50
+ import { fileURLToPath, pathToFileURL } from "node:url";
51
+ import { homedir } from "node:os";
52
+ import { setTimeout as delay } from "node:timers/promises";
53
+ import process from "node:process";
54
+
55
+ import {
56
+ assertPortsFree,
57
+ buildAgentServerCommand,
58
+ buildSafeDevConfig,
59
+ buildAgentServerEnv,
60
+ buildNpmScriptCommand,
61
+ buildRuntimeServicesInfo,
62
+ formatMissingUvxGuidance,
63
+ validateFrontendDependencies,
64
+ validateLocalAgentServerPath,
65
+ } from "./dev-safe.mjs";
66
+ import {
67
+ createShutdownHookRegistry,
68
+ getProcessTreeSpawnOptions,
69
+ isProcessRunning,
70
+ resolveWindowsCommand,
71
+ signalProcessTree,
72
+ } from "./dev-process-utils.mjs";
73
+ import { fileLog, stripAnsi } from "./logger.mjs";
74
+
75
+ const __dirname = dirname(fileURLToPath(import.meta.url));
76
+ const projectRoot = resolve(__dirname, "..");
77
+
78
+ // ── Centralized config (single source of truth for versions, ports, etc.) ───
79
+ const SHARED_DEFAULTS = JSON.parse(
80
+ readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
81
+ );
82
+
83
+ const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
84
+ const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation;
85
+ const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation;
86
+ const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer;
87
+ const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
88
+ const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation;
89
+ const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey;
90
+ const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost;
91
+
92
+ // ═══════════════════════════════════════════════════════════════════��═══════
93
+ // Terminal Styling
94
+ // ═══════════════════════════════════════════════════════════════════════════
95
+
96
+ const c = {
97
+ reset: "\x1b[0m",
98
+ bold: "\x1b[1m",
99
+ dim: "\x1b[2m",
100
+ red: "\x1b[31m",
101
+ green: "\x1b[32m",
102
+ yellow: "\x1b[33m",
103
+ blue: "\x1b[34m",
104
+ magenta: "\x1b[35m",
105
+ cyan: "\x1b[36m",
106
+ };
107
+
108
+ function logService(name, message, color = c.reset) {
109
+ const ts = new Date().toISOString().split("T")[1].split(".")[0];
110
+ console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
111
+ fileLog("info", `[${name}] ${stripAnsi(message)}`);
112
+ }
113
+
114
+ function logStep(step, message) {
115
+ console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
116
+ fileLog("info", `[${step}] ${message}`);
117
+ }
118
+
119
+ function logSuccess(message) {
120
+ console.log(`${c.green}βœ“${c.reset} ${message}`);
121
+ fileLog("info", `βœ“ ${message}`);
122
+ }
123
+
124
+ function logError(message) {
125
+ console.error(`${c.red}βœ—${c.reset} ${message}`);
126
+ fileLog("error", `βœ— ${stripAnsi(message)}`);
127
+ }
128
+
129
+ /**
130
+ * Parse one JSON log line produced by the SDK's JsonFormatter and return a
131
+ * single-line human-readable string + an appropriate ANSI color.
132
+ *
133
+ * Returns null for non-JSON lines so callers can fall back to the raw text.
134
+ *
135
+ * @param {string} rawLine
136
+ * @returns {{ text: string; color: string } | null}
137
+ */
138
+ function parseAgentServerLogLine(rawLine) {
139
+ try {
140
+ const obj = JSON.parse(rawLine);
141
+ if (!obj.levelname || obj.message === undefined) return null;
142
+ const level = obj.levelname.padEnd(8);
143
+ const location =
144
+ obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : "";
145
+ const text = `${level} ${obj.message}${location}`;
146
+ const lvl = obj.levelname;
147
+ const color =
148
+ lvl === "DEBUG"
149
+ ? c.dim
150
+ : lvl === "WARNING"
151
+ ? c.yellow
152
+ : lvl === "ERROR" || lvl === "CRITICAL"
153
+ ? c.red
154
+ : c.blue;
155
+ return { text, color };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ // ═══════════════════════════════════════════════════════════════════════════
162
+ // Configuration
163
+ // ═══════════════════════════════════════════════════════════════════════════
164
+
165
+ function parseArgs() {
166
+ const args = process.argv.slice(2);
167
+ const config = {
168
+ port: null,
169
+ automationGitRef: null,
170
+ automationRepo: null,
171
+ verbose: false,
172
+ static: false,
173
+ dynamic: false,
174
+ staticDir: null,
175
+ skipBuild: false,
176
+ public: false,
177
+ frontendOnly: false,
178
+ backendOnly: false,
179
+ };
180
+
181
+ for (let i = 0; i < args.length; i++) {
182
+ switch (args[i]) {
183
+ case "-p":
184
+ case "--port":
185
+ config.port = parseInt(args[++i], 10);
186
+ break;
187
+ case "--automation-ref":
188
+ config.automationGitRef = args[++i];
189
+ break;
190
+ case "--automation-repo":
191
+ config.automationRepo = args[++i];
192
+ break;
193
+ case "-v":
194
+ case "--verbose":
195
+ config.verbose = true;
196
+ break;
197
+ case "--static":
198
+ config.static = true;
199
+ break;
200
+ case "--dynamic":
201
+ config.dynamic = true;
202
+ break;
203
+ case "--static-dir":
204
+ config.staticDir = args[++i];
205
+ break;
206
+ case "--skip-build":
207
+ config.skipBuild = true;
208
+ break;
209
+ case "--public":
210
+ config.public = true;
211
+ break;
212
+ case "--frontend-only":
213
+ config.frontendOnly = true;
214
+ break;
215
+ case "--backend-only":
216
+ config.backendOnly = true;
217
+ break;
218
+ case "-h":
219
+ case "--help":
220
+ showHelp();
221
+ process.exit(0);
222
+ }
223
+ }
224
+
225
+ return config;
226
+ }
227
+
228
+ function showHelp() {
229
+ console.log(`
230
+ Agent Canvas + Automation Development Stack
231
+
232
+ Runs agent-canvas with the automation backend (via uvx, no clone needed).
233
+ Uses a standalone ingress proxy to route traffic.
234
+
235
+ USAGE:
236
+ node scripts/dev-with-automation.mjs [options]
237
+
238
+ OPTIONS:
239
+ -p, --port <port> Ingress port (default: 8000)
240
+ --automation-ref <ref> Git ref for automation (branch/tag/SHA)
241
+ --automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
242
+ --static Serve an existing production build instead of Vite
243
+ --static-dir <dir> Static build directory (default: build/)
244
+ --skip-build Reuse build/ when the launcher builds static assets
245
+ --dynamic Force Vite dev server when a wrapper defaults static
246
+ --frontend-only Start only the frontend behind ingress
247
+ --backend-only Start only agent-server + automation behind ingress
248
+ -v, --verbose Show detailed output
249
+ -h, --help Show this help
250
+
251
+ ENVIRONMENT VARIABLES:
252
+ PORT Alternative to --port
253
+ OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
254
+ OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
255
+ OH_AUTOMATION_LOCAL_PATH Absolute path to a local automation checkout (overridden only by --automation-git-ref)
256
+ OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence)
257
+ OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
258
+ OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
259
+ OH_SECRET_KEY Secret key for sessions
260
+
261
+ SECRETS:
262
+ The session API key is automatically seeded into agent-server secrets
263
+ as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
264
+ Both backends (agent-server and automation) share the same key value.
265
+ AUTOMATION_KV_SECRET defaults to the session key so the KV store works
266
+ out of the box; override with an explicit value for stronger isolation.
267
+
268
+ ACCESS POINTS:
269
+ Main UI: http://localhost:PORT/
270
+ API Docs: http://localhost:PORT/api/automation/docs
271
+ `);
272
+ }
273
+
274
+ /**
275
+ * Fail fast on an unusable OH_AUTOMATION_LOCAL_PATH instead of letting
276
+ * `uv run --project <bad path>` exit on its own -- that leaves the rest of the
277
+ * stack up and the automations UI just reporting "backend unavailable", with
278
+ * nothing pointing at the env var. Mirrors validateLocalAgentServerPath.
279
+ */
280
+ function validateLocalAutomationPath(localPath) {
281
+ if (!isAbsolute(localPath)) {
282
+ throw new Error(
283
+ `OH_AUTOMATION_LOCAL_PATH must be an absolute path, got: ${localPath}`,
284
+ );
285
+ }
286
+ if (!existsSync(localPath)) {
287
+ throw new Error(`OH_AUTOMATION_LOCAL_PATH does not exist: ${localPath}`);
288
+ }
289
+ const projectFile = join(localPath, "pyproject.toml");
290
+ if (!existsSync(projectFile)) {
291
+ throw new Error(
292
+ `OH_AUTOMATION_LOCAL_PATH is not a Python project (no pyproject.toml): ${projectFile}`,
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Build the uvx command for running automation backend.
299
+ *
300
+ * Environment variables (highest precedence first):
301
+ * - OH_AUTOMATION_LOCAL_PATH: Absolute path to a local checkout
302
+ * - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
303
+ * - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
304
+ *
305
+ * If none are set, defaults to the released version specified by
306
+ * DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
307
+ * git branch or commit instead.
308
+ */
309
+ function buildAutomationCommand(env = process.env) {
310
+ const localPath = env.OH_AUTOMATION_LOCAL_PATH;
311
+ const gitRef = env.OH_AUTOMATION_GIT_REF;
312
+ const version = env.OH_AUTOMATION_VERSION;
313
+ const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
314
+
315
+ const uvxArgs = [];
316
+ let source = "";
317
+
318
+ if (localPath) {
319
+ // Run straight from a local checkout via `uv run --project`, so
320
+ // uncommitted working-tree changes are picked up. Outranks the other
321
+ // automation env vars, mirroring OH_AGENT_SERVER_LOCAL_PATH for the
322
+ // agent-server SDK; buildConfig drops it when --automation-git-ref asks
323
+ // for a specific ref.
324
+ return {
325
+ command: "uv",
326
+ args: [
327
+ "run",
328
+ "--project",
329
+ localPath,
330
+ "uvicorn",
331
+ "openhands.automation.app:app",
332
+ ],
333
+ source: `local (${localPath})`,
334
+ };
335
+ }
336
+
337
+ if (gitRef) {
338
+ // Use git ref - refresh to ensure latest commit is fetched
339
+ const gitUrl = `git+${repoUrl}@${gitRef}`;
340
+ uvxArgs.push(
341
+ "--refresh",
342
+ "--from",
343
+ gitUrl,
344
+ "uvicorn",
345
+ "openhands.automation.app:app",
346
+ );
347
+ source = `git (${gitRef})`;
348
+ } else if (version) {
349
+ // Use specific PyPI version
350
+ uvxArgs.push(
351
+ "--from",
352
+ `${DEFAULT_AUTOMATION_PACKAGE}==${version}`,
353
+ "uvicorn",
354
+ "openhands.automation.app:app",
355
+ );
356
+ source = `PyPI (${version})`;
357
+ } else {
358
+ // Default to released PyPI version
359
+ uvxArgs.push(
360
+ "--from",
361
+ `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`,
362
+ "uvicorn",
363
+ "openhands.automation.app:app",
364
+ );
365
+ source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
366
+ }
367
+
368
+ return {
369
+ command: "uvx",
370
+ args: uvxArgs,
371
+ source,
372
+ };
373
+ }
374
+
375
+ async function buildConfig(args, env = process.env) {
376
+ // Apply args to env for buildAutomationCommand
377
+ if (args.automationGitRef) {
378
+ env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
379
+ // An explicit flag outranks an ambient env var. Otherwise someone with
380
+ // OH_AUTOMATION_LOCAL_PATH exported in their shell profile would run their
381
+ // own working tree while believing they were reproducing against the ref
382
+ // they just passed.
383
+ if (env.OH_AUTOMATION_LOCAL_PATH) {
384
+ logStep(
385
+ "automation",
386
+ `--automation-git-ref ${args.automationGitRef} overrides OH_AUTOMATION_LOCAL_PATH (${env.OH_AUTOMATION_LOCAL_PATH})`,
387
+ );
388
+ delete env.OH_AUTOMATION_LOCAL_PATH;
389
+ }
390
+ }
391
+ if (args.automationRepo) {
392
+ env.OH_AUTOMATION_REPO = args.automationRepo;
393
+ }
394
+
395
+ const frontendOnly = Boolean(args.frontendOnly);
396
+ const backendOnly = Boolean(args.backendOnly);
397
+ if (frontendOnly && backendOnly) {
398
+ throw new Error(
399
+ "--frontend-only and --backend-only cannot be used together",
400
+ );
401
+ }
402
+
403
+ const launchFrontend = !backendOnly;
404
+ const launchAgentServer = !frontendOnly;
405
+ const launchAutomation = !frontendOnly;
406
+ const isPublic = args.public;
407
+
408
+ if (isPublic && frontendOnly) {
409
+ throw new Error("--public cannot be used with --frontend-only");
410
+ }
411
+
412
+ // In public mode, LOCAL_BACKEND_API_KEY is required β€” without it the
413
+ // auth screen has nothing to validate against.
414
+ if (isPublic && !env.LOCAL_BACKEND_API_KEY) {
415
+ logError(
416
+ "PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" +
417
+ " Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public",
418
+ );
419
+ process.exit(1);
420
+ }
421
+
422
+ // Preferred ports (from env or defaults).
423
+ // OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
424
+ // OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
425
+ // internal service ports without affecting the production default.
426
+ const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
427
+ const preferredBackendPort =
428
+ parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
429
+ const preferredAutomationPort =
430
+ parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
431
+ const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;
432
+
433
+ // Fail fast if any preferred port for a service in this mode is already in use.
434
+ const requiredPorts = [{ name: "ingress", port: preferredIngressPort }];
435
+ if (launchAgentServer) {
436
+ requiredPorts.push({ name: "agent-server", port: preferredBackendPort });
437
+ }
438
+ if (launchAutomation) {
439
+ requiredPorts.push({ name: "automation", port: preferredAutomationPort });
440
+ }
441
+ if (launchFrontend) {
442
+ requiredPorts.push({ name: "frontend", port: preferredVitePort });
443
+ }
444
+
445
+ logStep("ports", "Checking ports...");
446
+ await assertPortsFree(requiredPorts);
447
+
448
+ const vscodePort = preferredBackendPort + 1000;
449
+
450
+ // API key β€” shared by both agent-server and automation backend.
451
+ // Both validate it via the `X-Session-API-Key` header.
452
+ // LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's
453
+ // used directly; otherwise one is auto-generated and persisted.
454
+ const stateDir =
455
+ env.OH_CANVAS_SAFE_STATE_DIR ||
456
+ join(homedir(), ".openhands", "agent-canvas");
457
+
458
+ const safeConfig = buildSafeDevConfig(projectRoot, {
459
+ ...env,
460
+ OH_CANVAS_SAFE_STATE_DIR: stateDir,
461
+ OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
462
+ OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
463
+ });
464
+ const sessionApiKey = safeConfig.sessionApiKey;
465
+
466
+ if (isPublic) {
467
+ logService(
468
+ "auth",
469
+ "PUBLIC MODE β€” key will NOT be injected into the frontend",
470
+ c.yellow,
471
+ );
472
+ logService(
473
+ "auth",
474
+ "Users must paste the LOCAL_BACKEND_API_KEY in the browser",
475
+ c.dim,
476
+ );
477
+ }
478
+
479
+ return {
480
+ // Ingress port (main entry point)
481
+ ingressPort: preferredIngressPort,
482
+
483
+ // Service ports (internal)
484
+ agentServerPort: preferredBackendPort,
485
+ autoBackendPort: preferredAutomationPort,
486
+ vitePort: preferredVitePort,
487
+ vscodePort,
488
+ // Prefix the editor is served under on the ingress origin. Carried on the
489
+ // config so the route table and the agent-server env are built from one
490
+ // value (see getLocalServiceRoutes / buildAgentServerEnv).
491
+ vscodeBasePath: safeConfig.vscodeBasePath,
492
+
493
+ // Paths
494
+ canvasPath: projectRoot,
495
+
496
+ // Data directories (same as dev-safe.mjs)
497
+ stateDir,
498
+ // Only bake the host-side workspace path when this launcher also starts
499
+ // the agent-server that can read it. In frontend-only mode the backend may
500
+ // be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
501
+ // user explicitly supplied a backend-relative value.
502
+ viteWorkingDir: launchAgentServer
503
+ ? safeConfig.workingDir
504
+ : env.VITE_WORKING_DIR,
505
+
506
+ // Auth β€” single key for both backends
507
+ sessionApiKey,
508
+
509
+ // Public mode β€” the session key should NOT be baked into the frontend
510
+ isPublic,
511
+
512
+ frontendOnly,
513
+ backendOnly,
514
+ launchFrontend,
515
+ launchAgentServer,
516
+ launchAutomation,
517
+
518
+ verbose: args.verbose,
519
+ };
520
+ }
521
+
522
+ // ═══════════════════════════════════════════════════════════════════════════
523
+ // Prerequisites & Setup
524
+ // ═══════════════════════════════════════════════════════════════════════════
525
+
526
+ function commandExists(cmd) {
527
+ const result =
528
+ process.platform === "win32"
529
+ ? spawnSync("where.exe", [cmd], { stdio: "pipe" })
530
+ : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
531
+
532
+ return result.status === 0;
533
+ }
534
+
535
+ function checkPrerequisites({
536
+ checkUvx = true,
537
+ checkNpm = true,
538
+ checkFrontendDependencies = true,
539
+ } = {}) {
540
+ logStep("1/2", "Checking prerequisites...");
541
+
542
+ if (checkUvx) {
543
+ if (!commandExists("uvx")) {
544
+ const uvxGuidance = formatMissingUvxGuidance(projectRoot);
545
+ console.error(uvxGuidance);
546
+ fileLog("error", stripAnsi(uvxGuidance));
547
+ process.exit(1);
548
+ }
549
+ logSuccess("uvx found");
550
+ }
551
+
552
+ if (checkNpm) {
553
+ if (!commandExists("npm")) {
554
+ logError("npm is required but not found");
555
+ process.exit(1);
556
+ }
557
+ logSuccess("npm found");
558
+ }
559
+
560
+ if (checkFrontendDependencies) {
561
+ try {
562
+ validateFrontendDependencies(projectRoot);
563
+ } catch (error) {
564
+ logError(error instanceof Error ? error.message : String(error));
565
+ process.exit(1);
566
+ }
567
+ logSuccess("frontend dependencies found");
568
+ }
569
+ }
570
+
571
+ function ensureDirectories(config) {
572
+ const dirs = [
573
+ config.stateDir,
574
+ // Both agent-server and automation use storage; create it unconditionally
575
+ // whenever either backend service runs (i.e. not frontend-only).
576
+ ...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []),
577
+ ];
578
+
579
+ if (config.launchAgentServer) {
580
+ dirs.push(
581
+ join(config.stateDir, "dev_conversations"),
582
+ join(config.stateDir, "workspaces"),
583
+ join(config.stateDir, "bash_events"),
584
+ );
585
+ }
586
+
587
+ if (config.launchAutomation) {
588
+ dirs.push(
589
+ // Automation DB directory β€” matches docker/entrypoint.sh mkdir -p behaviour.
590
+ dirname(
591
+ join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb),
592
+ ),
593
+ );
594
+ }
595
+
596
+ for (const dir of dirs) {
597
+ mkdirSync(dir, { recursive: true });
598
+ }
599
+ }
600
+
601
+ // ═══════════════════════════════════════════════════════════════════════════
602
+ // Process Management
603
+ // ═══════════════════════════════════════════════════════════════════════════
604
+
605
+ const processes = new Map();
606
+ const shutdownHooks = createShutdownHookRegistry((err) => {
607
+ logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow);
608
+ });
609
+
610
+ // Optional external listener for every service log line. Set by `main()` from
611
+ // its `onServiceLog` option so embedded launchers (e.g. the Electron desktop
612
+ // app) can stream uvx download / install progress to their loading window
613
+ // without touching the terminal logging path. Receives `(name, line, level)`
614
+ // where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error".
615
+ let serviceLogListener = null;
616
+
617
+ export function setServiceLogListener(listener) {
618
+ serviceLogListener = typeof listener === "function" ? listener : null;
619
+ }
620
+
621
+ function emitServiceLog(name, line, level) {
622
+ if (!serviceLogListener) return;
623
+ try {
624
+ serviceLogListener(name, line, level);
625
+ } catch {
626
+ // Never let a listener bug crash the dev stack.
627
+ }
628
+ }
629
+
630
+ function registerShutdownHook(hook) {
631
+ return shutdownHooks.add(hook);
632
+ }
633
+
634
+ function spawnService(name, command, args, options = {}) {
635
+ const proc = spawn(
636
+ resolveWindowsCommand(command),
637
+ args,
638
+ getProcessTreeSpawnOptions({
639
+ stdio: ["ignore", "pipe", "pipe"],
640
+ env: { ...process.env, ...options.env },
641
+ cwd: options.cwd,
642
+ }),
643
+ );
644
+
645
+ const color = options.color || c.reset;
646
+ const parseLogLine = options.parseLogLine;
647
+
648
+ proc.stdout.on("data", (data) => {
649
+ data
650
+ .toString()
651
+ .split("\n")
652
+ .filter(Boolean)
653
+ .forEach((line) => {
654
+ const trimmed = line.trim();
655
+ const parsed = parseLogLine ? parseLogLine(trimmed) : null;
656
+ logService(
657
+ name,
658
+ parsed ? parsed.text : trimmed,
659
+ parsed ? parsed.color : color,
660
+ );
661
+ emitServiceLog(name, trimmed, "stdout");
662
+ });
663
+ });
664
+
665
+ proc.stderr.on("data", (data) => {
666
+ data
667
+ .toString()
668
+ .split("\n")
669
+ .filter(Boolean)
670
+ .forEach((line) => {
671
+ const trimmed = line.trim();
672
+ const parsed = parseLogLine ? parseLogLine(trimmed) : null;
673
+ logService(
674
+ name,
675
+ parsed ? parsed.text : trimmed,
676
+ parsed ? parsed.color : c.yellow,
677
+ );
678
+ emitServiceLog(name, trimmed, "stderr");
679
+ });
680
+ });
681
+
682
+ proc.on("error", (error) => {
683
+ logError(`${name} failed to start: ${error.message}`);
684
+ emitServiceLog(name, `failed to start: ${error.message}`, "error");
685
+ });
686
+
687
+ proc.on("exit", (code, _signal) => {
688
+ if (code !== 0 && code !== null && !shuttingDown) {
689
+ logService(name, `Exited with code ${code}`, c.red);
690
+ emitServiceLog(name, `exited with code ${code}`, "error");
691
+ }
692
+ processes.delete(name);
693
+ });
694
+
695
+ processes.set(name, proc);
696
+ return proc;
697
+ }
698
+
699
+ async function waitForService(name, url, timeoutMs = 30000) {
700
+ const start = Date.now();
701
+ let lastError = null;
702
+
703
+ while (Date.now() - start < timeoutMs) {
704
+ try {
705
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
706
+ if (res.ok) {
707
+ logService(name, `Ready at ${url}`, c.green);
708
+ return true;
709
+ }
710
+ } catch (err) {
711
+ lastError = err;
712
+ // Keep trying
713
+ }
714
+ await delay(500);
715
+ }
716
+
717
+ const elapsed = Math.round((Date.now() - start) / 1000);
718
+ logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
719
+ if (lastError) {
720
+ logService(name, `Last error: ${lastError.message}`, c.dim);
721
+ }
722
+ return false;
723
+ }
724
+
725
+ // ═══════════════════════════════════════════════════════════════════════════
726
+ // Service Starters
727
+ // ═══════════════════════════════════════════════════════════════════════════
728
+
729
+ const AUTOMATION_ROUTE_PREFIX = "/api/automation";
730
+ const AGENT_SERVER_ROUTE_PREFIXES = [
731
+ "/api",
732
+ "/sockets",
733
+ "/server_info",
734
+ "/health",
735
+ "/ready",
736
+ "/alive",
737
+ "/docs",
738
+ "/redoc",
739
+ "/openapi.json",
740
+ ];
741
+
742
+ // This launcher starts the agent-server with `--host 127.0.0.1`, but localhost
743
+ // can resolve to ::1 first (notably on Windows), so every request this process
744
+ // or the automation backend makes to it must address IPv4 explicitly.
745
+ function getAgentServerBaseUrl(config) {
746
+ return `http://127.0.0.1:${config.agentServerPort}`;
747
+ }
748
+
749
+ function getLocalServiceRoutes(config) {
750
+ const routes = [];
751
+
752
+ // These services bind to IPv4 loopback, but localhost can resolve to ::1.
753
+ if (config.launchAutomation) {
754
+ routes.push([
755
+ AUTOMATION_ROUTE_PREFIX,
756
+ `http://127.0.0.1:${config.autoBackendPort}`,
757
+ ]);
758
+ }
759
+
760
+ if (config.launchAgentServer) {
761
+ for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
762
+ routes.push([prefix, getAgentServerBaseUrl(config)]);
763
+ }
764
+
765
+ // The editor is a separate process on its own port, but it is reached
766
+ // through the same origin as the canvas so no second port has to be
767
+ // published. The prefix is deliberately preserved rather than stripped:
768
+ // agent-server launches openvscode-server with `--server-base-path`, so
769
+ // the editor generates its own HTTP and WebSocket URLs beneath the prefix
770
+ // and only answers there. `createRouter` matches the longest prefix and
771
+ // the proxy forwards the original path, so both are already handled.
772
+ if (config.vscodeBasePath) {
773
+ routes.push([
774
+ config.vscodeBasePath,
775
+ `http://127.0.0.1:${config.vscodePort}`,
776
+ ]);
777
+ }
778
+ }
779
+
780
+ return routes;
781
+ }
782
+
783
+ function buildRouteArgs(routes) {
784
+ return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
785
+ }
786
+
787
+ /**
788
+ * The editor prefix, if this mode serves it, as `--no-referrer-prefix` args.
789
+ *
790
+ * agent-server hands the editor a connection token derived from its session
791
+ * key and advertises it in the URL's query string, so the workbench document
792
+ * must not leak a Referer to the subresources it loads.
793
+ */
794
+ function getNoReferrerPrefixArgs(config) {
795
+ if (!config.launchAgentServer || !config.vscodeBasePath) return [];
796
+ return ["--no-referrer-prefix", config.vscodeBasePath];
797
+ }
798
+
799
+ /**
800
+ * The editor prefix, if this mode serves it, as `--vscode-base-path` args.
801
+ *
802
+ * Gated on exactly the same condition as the editor route in
803
+ * `getLocalServiceRoutes`, because they answer the same question: an origin
804
+ * advertises the editor if and only if it routes it. static-server enforces
805
+ * that pairing at startup, so a future edit that breaks it fails loudly rather
806
+ * than shipping a control that opens the SPA.
807
+ */
808
+ function getVSCodeAdvertiseArgs(config) {
809
+ if (!config.launchAgentServer || !config.vscodeBasePath) return [];
810
+ return ["--vscode-base-path", config.vscodeBasePath];
811
+ }
812
+
813
+ /**
814
+ * Build --reject-prefix args for the static server.
815
+ * In frontend-only mode, API paths that have no backend should return 503
816
+ * instead of being SPA-fallbacked to index.html.
817
+ */
818
+ function getRejectPrefixes(config) {
819
+ const prefixes = [];
820
+ if (!config.launchAutomation) {
821
+ prefixes.push(AUTOMATION_ROUTE_PREFIX);
822
+ }
823
+ if (!config.launchAgentServer) {
824
+ for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
825
+ prefixes.push(prefix);
826
+ }
827
+ // No agent-server means no editor behind this prefix either. Reject it
828
+ // rather than SPA-fallbacking to index.html, which would answer an editor
829
+ // request with the canvas shell.
830
+ if (config.vscodeBasePath) {
831
+ prefixes.push(config.vscodeBasePath);
832
+ }
833
+ }
834
+ return prefixes;
835
+ }
836
+
837
+ function buildRejectPrefixArgs(prefixes) {
838
+ return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]);
839
+ }
840
+
841
+ function getFrontendBackend(config) {
842
+ return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
843
+ }
844
+
845
+ function buildViteBackendEnv(config, env = process.env) {
846
+ // VITE_BACKEND_HOST tells the Vite dev-server proxy (vite.config.ts) where
847
+ // to forward /api, /sockets, etc. It is NOT read by the frontend at
848
+ // runtime, so it is safe to keep as an absolute address.
849
+ //
850
+ // VITE_BACKEND_BASE_URL is intentionally left unset so the frontend falls
851
+ // back to window.location.origin (same-origin) at runtime β€” matching the
852
+ // behaviour of dev:static / agent-canvas and keeping the dev server
853
+ // portable across localhost, LAN hosts, SSH tunnels, and ngrok.
854
+ const backendHost = config.launchAgentServer
855
+ ? `127.0.0.1:${config.ingressPort}`
856
+ : (env.VITE_BACKEND_HOST ??
857
+ env.VITE_BACKEND_BASE_URL?.replace(/^https?:\/\//, "") ??
858
+ "127.0.0.1:8000");
859
+
860
+ const env_out = { VITE_BACKEND_HOST: backendHost };
861
+
862
+ // If the user supplied VITE_BACKEND_BASE_URL with an https:// scheme and
863
+ // did not explicitly set VITE_USE_TLS, propagate the HTTPS intent so the
864
+ // Vite proxy forwards over TLS instead of plain HTTP.
865
+ if (
866
+ !config.launchAgentServer &&
867
+ env.VITE_BACKEND_BASE_URL?.startsWith("https://") &&
868
+ env.VITE_USE_TLS === undefined
869
+ ) {
870
+ env_out.VITE_USE_TLS = "true";
871
+ }
872
+
873
+ return env_out;
874
+ }
875
+
876
+ function buildAgentServerAutomationEnv(config) {
877
+ return {
878
+ // Make the session API key available to terminal commands spawned by the
879
+ // agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds
880
+ // this into Settings > Secrets, but agents commonly create automations
881
+ // with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`;
882
+ // exposing it here keeps that path working even before/without
883
+ // secret-registry env expansion.
884
+ OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey,
885
+ };
886
+ }
887
+
888
+ function buildAutomationTelemetryEnv(env = process.env) {
889
+ const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1";
890
+ const apiKey =
891
+ env.AUTOMATION_POSTHOG_API_KEY ||
892
+ env.VITE_POSTHOG_API_KEY ||
893
+ (telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY);
894
+
895
+ if (!apiKey) return {};
896
+
897
+ return {
898
+ AUTOMATION_POSTHOG_API_KEY: apiKey,
899
+ AUTOMATION_POSTHOG_HOST:
900
+ env.AUTOMATION_POSTHOG_HOST ||
901
+ env.VITE_POSTHOG_HOST ||
902
+ DEFAULT_POSTHOG_HOST,
903
+ };
904
+ }
905
+
906
+ function startAgentServer(config) {
907
+ logService(
908
+ "agent-server",
909
+ `Starting on port ${config.agentServerPort}...`,
910
+ c.blue,
911
+ );
912
+
913
+ const agentServerCmd = buildAgentServerCommand(process.env);
914
+ logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
915
+
916
+ // Build safe config for agent-server env vars
917
+ const safeConfig = buildSafeDevConfig(config.canvasPath, {
918
+ ...process.env,
919
+ OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
920
+ OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
921
+ OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
922
+ });
923
+
924
+ const agentServerEnv = {
925
+ // Opt into prefix-mode: `getLocalServiceRoutes` registers the matching
926
+ // route on both the static server and the ingress, so the prefix this
927
+ // advertises resolves to the editor port on the canvas origin.
928
+ ...buildAgentServerEnv(safeConfig, {
929
+ vscodeBasePath: config.vscodeBasePath,
930
+ }),
931
+ ...buildAgentServerAutomationEnv(config),
932
+ OPENHANDS_REMOTE_WS_READY_REQUIRED:
933
+ process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
934
+ // Ensure the agent-server uses the resolved key from config. This is
935
+ // LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
936
+ OH_SESSION_API_KEYS_0: config.sessionApiKey,
937
+ // Emit structured JSON log lines instead of Rich-formatted output.
938
+ // Rich wraps long messages across multiple lines and prepends its own
939
+ // timestamp; LOG_JSON=true produces one JSON object per record which
940
+ // parseAgentServerLogLine re-formats into a clean single-line entry.
941
+ LOG_JSON: "true",
942
+ };
943
+
944
+ spawnService(
945
+ "agent-server",
946
+ agentServerCmd.command,
947
+ [
948
+ ...agentServerCmd.args,
949
+ "--host",
950
+ "127.0.0.1",
951
+ "--port",
952
+ String(config.agentServerPort),
953
+ ],
954
+ {
955
+ cwd: safeConfig.workspacesPath,
956
+ env: agentServerEnv,
957
+ color: c.blue,
958
+ parseLogLine: parseAgentServerLogLine,
959
+ },
960
+ );
961
+ }
962
+
963
+ function startAutomationBackend(config) {
964
+ logService(
965
+ "automation",
966
+ `Starting on port ${config.autoBackendPort}...`,
967
+ c.green,
968
+ );
969
+
970
+ const automationCmd = buildAutomationCommand(process.env);
971
+ logService("automation", `Using ${automationCmd.source}`, c.dim);
972
+
973
+ spawnService(
974
+ "automation",
975
+ automationCmd.command,
976
+ [
977
+ ...automationCmd.args,
978
+ "--host",
979
+ "127.0.0.1",
980
+ "--port",
981
+ config.autoBackendPort.toString(),
982
+ ],
983
+ {
984
+ cwd: config.stateDir,
985
+ env: {
986
+ // Force UTF-8 for all Python file I/O (same reason as agent-server;
987
+ // see buildAgentServerEnv in dev-safe.mjs).
988
+ PYTHONUTF8: "1",
989
+ OPENHANDS_REMOTE_WS_READY_REQUIRED:
990
+ process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
991
+ // The URL the automation backend itself uses to call the
992
+ // agent-server's REST API (tarball upload + bash dispatch).
993
+ //
994
+ // Priority:
995
+ // 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env
996
+ // 2. `127.0.0.1:<agentServerPort>`
997
+ AUTOMATION_AGENT_SERVER_URL:
998
+ process.env.AUTOMATION_AGENT_SERVER_URL ||
999
+ getAgentServerBaseUrl(config),
1000
+ // The URL exported into the in-sandbox bash chain as
1001
+ // `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into
1002
+ // the agent-server).
1003
+ //
1004
+ // Priority:
1005
+ // 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env
1006
+ // 2. launcher-provided value
1007
+ // 3. unset β€” backend falls back to AUTOMATION_AGENT_SERVER_URL
1008
+ ...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
1009
+ config.sandboxAgentServerUrl
1010
+ ? {
1011
+ AUTOMATION_SANDBOX_AGENT_SERVER_URL:
1012
+ process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
1013
+ config.sandboxAgentServerUrl,
1014
+ }
1015
+ : {}),
1016
+ AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
1017
+ // ~/.openhands/automation/automations.db β€” matches docker/entrypoint.sh.
1018
+ AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`,
1019
+ // The automation backend uses this as its publicly-reachable base
1020
+ // URL: it's appended to callback URLs and injected into each
1021
+ // sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for
1022
+ // /sdk-version and by the SDK for run completion).
1023
+ // Priority:
1024
+ // 1. AUTOMATION_BASE_URL explicitly set in the user's env
1025
+ // 2. launcher-provided host
1026
+ // 3. `localhost`
1027
+ AUTOMATION_BASE_URL:
1028
+ process.env.AUTOMATION_BASE_URL ||
1029
+ `http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`,
1030
+ // The dispatcher resolves this path and embeds it into a
1031
+ // `mkdir -p ...` shell command executed by the agent-server.
1032
+ // Priority:
1033
+ // 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env
1034
+ // 2. `automationWorkspaceBase` option passed by the launcher
1035
+ // 3. host-side default under config.stateDir
1036
+ AUTOMATION_WORKSPACE_BASE:
1037
+ process.env.AUTOMATION_WORKSPACE_BASE ||
1038
+ config.automationWorkspaceBase ||
1039
+ join(config.stateDir, "workspaces"),
1040
+ // Session API key for self-hosted auth β€” shared with agent-server via X-Session-API-Key header
1041
+ AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
1042
+ ...buildAutomationTelemetryEnv(),
1043
+ // KV store secret β€” required for automations to use the built-in
1044
+ // key-value store for state persistence between runs. Used for JWT
1045
+ // signing and value encryption.
1046
+ // Priority:
1047
+ // 1. AUTOMATION_KV_SECRET explicitly set in the user's env
1048
+ // 2. sessionApiKey β€” convenient zero-config default for local dev
1049
+ AUTOMATION_KV_SECRET:
1050
+ process.env.AUTOMATION_KV_SECRET || config.sessionApiKey,
1051
+ // CORS: allow localhost origins for dev, unless explicitly overridden.
1052
+ AUTOMATION_CORS_ORIGINS:
1053
+ process.env.AUTOMATION_CORS_ORIGINS ||
1054
+ `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
1055
+ FILE_STORE: "local",
1056
+ LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
1057
+ OPENHANDS_SUPPRESS_BANNER: "1",
1058
+ },
1059
+ color: c.green,
1060
+ },
1061
+ );
1062
+ }
1063
+
1064
+ // ═══════════════════════════════════════════════════════════════════════════
1065
+ // Main
1066
+ // ═══════════════════════════════════════════════════════════════════════════
1067
+
1068
+ let shuttingDown = false;
1069
+
1070
+ function shutdown() {
1071
+ if (shuttingDown) return;
1072
+ shuttingDown = true;
1073
+
1074
+ console.log("");
1075
+ console.log(`${c.yellow}Shutting down...${c.reset}`);
1076
+ fileLog("info", "Shutting down...");
1077
+
1078
+ for (const [name, proc] of processes) {
1079
+ logService(name, "Stopping...", c.dim);
1080
+ signalProcessTree(proc, "SIGTERM");
1081
+ }
1082
+
1083
+ setTimeout(() => {
1084
+ for (const [name, proc] of processes) {
1085
+ if (isProcessRunning(proc)) {
1086
+ logService(name, "Force stopping...", c.dim);
1087
+ signalProcessTree(proc, "SIGKILL");
1088
+ }
1089
+ }
1090
+ shutdownHooks.run();
1091
+ process.exit(0);
1092
+ }, 3000);
1093
+ }
1094
+
1095
+ process.on("SIGINT", shutdown);
1096
+ process.on("SIGTERM", shutdown);
1097
+ process.on("SIGHUP", shutdown);
1098
+
1099
+ function startIngress(config) {
1100
+ logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
1101
+
1102
+ const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
1103
+ const frontendBackend = getFrontendBackend(config);
1104
+ const runtimeServicesInfo = config.launchAgentServer
1105
+ ? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
1106
+ : null;
1107
+
1108
+ spawnService(
1109
+ "ingress",
1110
+ "node",
1111
+ [
1112
+ ingressScript,
1113
+ "--port",
1114
+ config.ingressPort.toString(),
1115
+ ...(runtimeServicesInfo
1116
+ ? ["--runtime-services-info", runtimeServicesInfo]
1117
+ : []),
1118
+ ...buildRouteArgs(getLocalServiceRoutes(config)),
1119
+ ...getNoReferrerPrefixArgs(config),
1120
+ ...(frontendBackend ? ["--default", frontendBackend] : []),
1121
+ ],
1122
+ {
1123
+ cwd: projectRoot,
1124
+ color: c.yellow,
1125
+ },
1126
+ );
1127
+ }
1128
+
1129
+ /**
1130
+ * Build the JSON-serializable runtime services info for an automation
1131
+ * stack. Backend-serving processes append this to `/server_info` so any
1132
+ * frontend connected to the backend can populate the agent's
1133
+ * `<RUNTIME_SERVICES>` system-prompt block.
1134
+ */
1135
+ export function buildAutomationRuntimeServicesInfo(config) {
1136
+ return buildRuntimeServicesInfo({
1137
+ mode: config.mode ?? "dev:automation",
1138
+ agentHostAlias: config.agentHostAlias ?? "localhost",
1139
+ agentServerPort: config.agentServerPort,
1140
+ ingressPort: config.ingressPort,
1141
+ frontendPort: config.launchFrontend ? config.vitePort : undefined,
1142
+ // The same port hosts Vite in dynamic mode and a static-file server
1143
+ // in static mode. The launcher records this on the config so the
1144
+ // description shown to the agent matches reality.
1145
+ frontendKind: config.frontendKind ?? "vite",
1146
+ automation: config.launchAutomation
1147
+ ? { port: config.autoBackendPort }
1148
+ : undefined,
1149
+ });
1150
+ }
1151
+
1152
+ function startVite(config) {
1153
+ logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
1154
+
1155
+ const frontendCommand = buildNpmScriptCommand("dev:frontend");
1156
+
1157
+ const viteEnv = {
1158
+ // Full-stack mode points Vite at this launcher's ingress. Frontend-only
1159
+ // mode uses the separately running backend ingress instead.
1160
+ ...buildViteBackendEnv(config),
1161
+ VITE_FRONTEND_PORT: config.vitePort.toString(),
1162
+ };
1163
+ if (config.viteWorkingDir) {
1164
+ viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
1165
+ }
1166
+
1167
+ // Vite serves the HTML for this mode's browser origin, so this is where the
1168
+ // editor-capability advertisement has to be baked. The ingress in front of it
1169
+ // routes the prefix but is a pure proxy β€” it injects nothing into the
1170
+ // document, so it cannot tell the frontend what it serves.
1171
+ //
1172
+ // Both variables or neither: `vite.config.ts` only registers the editor proxy
1173
+ // when it has a target as well as a prefix, and this stack has two supported
1174
+ // browser origins β€” the ingress and Vite's own port, which is why the latter
1175
+ // is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the
1176
+ // ingress itself; on the Vite origin only this proxy can serve it. Baking the
1177
+ // prefix alone would advertise an editor on the Vite origin whose URL then
1178
+ // falls through to the SPA β€” the dead button this gating exists to prevent.
1179
+ if (config.launchAgentServer && config.vscodeBasePath) {
1180
+ viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath;
1181
+ viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`;
1182
+ }
1183
+
1184
+ // In local mode, bake the session key into the frontend so the user
1185
+ // never has to paste it. In public mode, omit the key and set
1186
+ // VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
1187
+ // immediately (no network round-trip needed).
1188
+ if (config.launchAgentServer && config.isPublic) {
1189
+ viteEnv.VITE_AUTH_REQUIRED = "true";
1190
+ } else if (config.launchAgentServer) {
1191
+ viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey;
1192
+ }
1193
+
1194
+ spawnService("vite", frontendCommand.command, frontendCommand.args, {
1195
+ cwd: config.canvasPath,
1196
+ env: viteEnv,
1197
+ color: c.magenta,
1198
+ });
1199
+ }
1200
+
1201
+ /**
1202
+ * Seed the session API key into agent-server's secrets store as
1203
+ * OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the
1204
+ * automation backend in curl commands during conversations.
1205
+ *
1206
+ * Includes retry logic to handle slow server startup or transient failures.
1207
+ *
1208
+ * @param {object} config - Configuration object with agentServerPort, sessionApiKey
1209
+ * @param {object} options - Options for retry behavior
1210
+ * @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
1211
+ * @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
1212
+ * @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
1213
+ * @returns {Promise<boolean>} True if seeding succeeded, false otherwise
1214
+ */
1215
+ async function seedAutomationSecret(config, options = {}) {
1216
+ const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options;
1217
+
1218
+ const secretName = "OPENHANDS_AUTOMATION_API_KEY";
1219
+ const secretDescription =
1220
+ "API key for authenticating with the automation backend";
1221
+
1222
+ logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
1223
+
1224
+ const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`;
1225
+ const body = JSON.stringify({
1226
+ name: secretName,
1227
+ value: config.sessionApiKey,
1228
+ description: secretDescription,
1229
+ });
1230
+
1231
+ const headers = {
1232
+ "Content-Type": "application/json",
1233
+ // Include session API key if configured
1234
+ ...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
1235
+ };
1236
+
1237
+ let lastError = null;
1238
+
1239
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1240
+ try {
1241
+ const response = await fetch(url, {
1242
+ method: "PUT",
1243
+ headers,
1244
+ body,
1245
+ signal: AbortSignal.timeout(timeoutMs),
1246
+ });
1247
+
1248
+ if (response.ok) {
1249
+ logService("secrets", `${secretName} seeded successfully`, c.green);
1250
+ return true;
1251
+ }
1252
+
1253
+ const text = await response.text();
1254
+ lastError = `HTTP ${response.status}: ${text}`;
1255
+
1256
+ // Don't retry on authentication errors - they won't resolve with retries
1257
+ if (response.status === 401 || response.status === 403) {
1258
+ logService(
1259
+ "secrets",
1260
+ `Warning: Failed to seed secret (${response.status}): ${text}`,
1261
+ c.yellow,
1262
+ );
1263
+ return false;
1264
+ }
1265
+
1266
+ // Retry on server errors or service unavailable
1267
+ if (attempt < maxRetries) {
1268
+ logService(
1269
+ "secrets",
1270
+ `Retry ${attempt}/${maxRetries} after ${response.status}...`,
1271
+ c.dim,
1272
+ );
1273
+ await delay(retryDelayMs);
1274
+ }
1275
+ } catch (err) {
1276
+ lastError = err.message;
1277
+
1278
+ // Connection errors likely mean server isn't ready - wait and retry
1279
+ if (attempt < maxRetries) {
1280
+ logService(
1281
+ "secrets",
1282
+ `Retry ${attempt}/${maxRetries}: ${err.message}`,
1283
+ c.dim,
1284
+ );
1285
+ await delay(retryDelayMs);
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ logService(
1291
+ "secrets",
1292
+ `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`,
1293
+ c.yellow,
1294
+ );
1295
+ return false;
1296
+ }
1297
+
1298
+ function printBanner(config) {
1299
+ const stackName = config.frontendOnly
1300
+ ? "Agent Canvas Frontend Stack"
1301
+ : config.backendOnly
1302
+ ? "Agent Canvas Backend Stack"
1303
+ : "Agent Canvas + Automation Stack";
1304
+
1305
+ // padEnd counts invisible ANSI escape bytes as visible characters, so we
1306
+ // compute the visible length separately and pad with spaces accordingly.
1307
+ const ansiEscape = String.fromCharCode(27);
1308
+ const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g");
1309
+ const ansiPadEnd = (str, targetVisible) => {
1310
+ const visible = str.replace(ansiRe, "").length;
1311
+ return str + " ".repeat(Math.max(0, targetVisible - visible));
1312
+ };
1313
+ // The box has 62-char inner width; each content line needs 63 visible chars
1314
+ // before the trailing border (1 leading β•‘ + 62 inner).
1315
+ const BOX_INNER = 63;
1316
+
1317
+ console.log("");
1318
+ console.log(
1319
+ `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
1320
+ );
1321
+ console.log(
1322
+ ansiPadEnd(
1323
+ `${c.green}${c.bold}β•‘${c.reset} ${c.bold}${stackName}${c.reset}`,
1324
+ BOX_INNER,
1325
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
1326
+ );
1327
+ console.log(
1328
+ `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
1329
+ );
1330
+ console.log(
1331
+ `${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
1332
+ );
1333
+ console.log(
1334
+ ansiPadEnd(
1335
+ `${c.green}${c.bold}β•‘${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
1336
+ BOX_INNER,
1337
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
1338
+ );
1339
+ if (config.launchFrontend) {
1340
+ console.log(
1341
+ ansiPadEnd(
1342
+ `${c.green}${c.bold}β•‘${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
1343
+ BOX_INNER,
1344
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
1345
+ );
1346
+ }
1347
+ if (config.launchAutomation) {
1348
+ console.log(
1349
+ ansiPadEnd(
1350
+ `${c.green}${c.bold}β•‘${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`,
1351
+ BOX_INNER,
1352
+ ) + `${c.green}${c.bold}β•‘${c.reset}`,
1353
+ );
1354
+ }
1355
+ console.log(
1356
+ `${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
1357
+ );
1358
+ console.log(
1359
+ `${c.green}${c.bold}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${c.reset}`,
1360
+ );
1361
+ console.log("");
1362
+ console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
1363
+ console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
1364
+ console.log("");
1365
+
1366
+ // Write a compact plain-text summary to the log file.
1367
+ const summary = [
1368
+ `${stackName} β€” started`,
1369
+ ` Ingress: http://localhost:${config.ingressPort}/`,
1370
+ ...(config.launchFrontend
1371
+ ? [` Main UI: http://localhost:${config.ingressPort}/`]
1372
+ : []),
1373
+ ...(config.launchAutomation
1374
+ ? [
1375
+ ` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`,
1376
+ ]
1377
+ : []),
1378
+ ` State directory: ${config.stateDir}`,
1379
+ ];
1380
+ fileLog("info", summary.join("\n"));
1381
+ }
1382
+
1383
+ async function main(options = {}) {
1384
+ const {
1385
+ bannerTitle = "Agent Canvas + Automation Development Stack",
1386
+ startAgentServer: startAgentServerOverride,
1387
+ extraPrereqs,
1388
+ viteWorkingDir,
1389
+ // Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend.
1390
+ // Defaults to a host-side path under config.stateDir.
1391
+ automationWorkspaceBase,
1392
+ // Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox
1393
+ // uses to call back into the automation backend). Defaults to `localhost`.
1394
+ automationApiHost,
1395
+ // Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the
1396
+ // automation backend. This is the URL the in-sandbox bash chain uses
1397
+ // to reach the agent-server. When unset the backend falls back to
1398
+ // AUTOMATION_AGENT_SERVER_URL.
1399
+ sandboxAgentServerUrl,
1400
+ staticMode: staticModeOverride,
1401
+ defaultStaticMode = false,
1402
+ buildStaticFrontend,
1403
+ staticDir: staticDirOverride,
1404
+ // Hostname the agent uses to reach services running on the host.
1405
+ agentHostAlias = "localhost",
1406
+ // Human-readable label for the dev mode, surfaced in the agent's
1407
+ // <RUNTIME_SERVICES> system-prompt block.
1408
+ mode = "dev:automation",
1409
+ // When true, enable public mode (require LOCAL_BACKEND_API_KEY,
1410
+ // don't bake session key into frontend).
1411
+ isPublic: isPublicOverride,
1412
+ // When true, skip the npm prerequisite check. Used by the Electron desktop
1413
+ // launcher where npm is not needed at runtime in static mode.
1414
+ skipNpmCheck = false,
1415
+ // How long to wait for the agent-server's `/server_info` to return 200
1416
+ // before continuing. Defaults to 60 s, which is fine for warm-cache dev
1417
+ // workflows. The Electron desktop launcher bumps this to several minutes
1418
+ // because first-launch on a fresh machine runs `uvx` to download Python
1419
+ // and install `openhands-agent-server` from PyPI, which can take much
1420
+ // longer than 60 s on a slow network.
1421
+ agentServerReadyTimeoutMs = 60_000,
1422
+ // Optional `(name, line, level)` callback that receives every service log
1423
+ // line (stdout, stderr, and lifecycle events) emitted by any spawned
1424
+ // backend process. Used by the Electron loading screen to surface uvx
1425
+ // download / install progress to the user. `level` is one of
1426
+ // "stdout" | "stderr" | "info" | "warn" | "error".
1427
+ onServiceLog,
1428
+ } = options;
1429
+
1430
+ // Install the listener early so log lines emitted before the first
1431
+ // `spawnService` call (e.g. by future setup steps) are also captured.
1432
+ setServiceLogListener(onServiceLog);
1433
+
1434
+ const args = parseArgs();
1435
+
1436
+ // Allow options to override CLI args for public mode
1437
+ if (isPublicOverride != null) {
1438
+ args.public = isPublicOverride;
1439
+ }
1440
+
1441
+ // Allow options to override CLI args (for bin/agent-canvas.mjs)
1442
+ const useStaticMode =
1443
+ staticModeOverride ??
1444
+ (args.dynamic ? false : args.static || defaultStaticMode);
1445
+ const staticDir =
1446
+ staticDirOverride ?? args.staticDir ?? join(projectRoot, "build");
1447
+
1448
+ const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : "";
1449
+ const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;
1450
+
1451
+ console.log("");
1452
+ console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
1453
+ console.log("");
1454
+ fileLog("info", titleWithMode);
1455
+
1456
+ // Setup phase
1457
+ checkPrerequisites({
1458
+ checkUvx: !args.frontendOnly,
1459
+ // Static-mode + backend-only has no frontend to build, so npm is not
1460
+ // required β€” unless the caller provides a custom buildStaticFrontend hook.
1461
+ // The Electron desktop launcher passes `skipNpmCheck: true` because the
1462
+ // packaged binary serves a pre-built static frontend and never invokes
1463
+ // npm at runtime, so we suppress the check unconditionally there.
1464
+ checkNpm:
1465
+ !skipNpmCheck &&
1466
+ ((!useStaticMode && !args.backendOnly) ||
1467
+ typeof buildStaticFrontend === "function"),
1468
+ checkFrontendDependencies:
1469
+ (!useStaticMode && !args.backendOnly) ||
1470
+ typeof buildStaticFrontend === "function",
1471
+ });
1472
+
1473
+ // Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste
1474
+ // time allocating ports / generating keys / launching uvx with a path that
1475
+ // would only produce a cryptic build error. Mirrors dev-safe.mjs and
1476
+ // dev-extra-backend.mjs.
1477
+ if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) {
1478
+ try {
1479
+ validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
1480
+ } catch (error) {
1481
+ logError(error instanceof Error ? error.message : String(error));
1482
+ process.exit(1);
1483
+ }
1484
+ }
1485
+
1486
+ // Same for the automation checkout -- skipped when --automation-git-ref was
1487
+ // passed, since buildConfig drops the env var in favor of the explicit flag.
1488
+ if (
1489
+ !args.frontendOnly &&
1490
+ !args.automationGitRef &&
1491
+ process.env.OH_AUTOMATION_LOCAL_PATH
1492
+ ) {
1493
+ try {
1494
+ validateLocalAutomationPath(process.env.OH_AUTOMATION_LOCAL_PATH);
1495
+ } catch (error) {
1496
+ logError(error instanceof Error ? error.message : String(error));
1497
+ process.exit(1);
1498
+ }
1499
+ }
1500
+
1501
+ // Build config with dynamic port allocation
1502
+ const config = await buildConfig(args);
1503
+ if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
1504
+ if (automationWorkspaceBase) {
1505
+ config.automationWorkspaceBase = automationWorkspaceBase;
1506
+ }
1507
+ if (automationApiHost) {
1508
+ config.automationApiHost = automationApiHost;
1509
+ }
1510
+ if (sandboxAgentServerUrl) {
1511
+ config.sandboxAgentServerUrl = sandboxAgentServerUrl;
1512
+ }
1513
+ // Stamp the dev-mode label, host alias, and frontend kind on the config
1514
+ // so downstream helpers (Vite spawn, static build) can produce a
1515
+ // runtime-services info object describing what the agent can reach.
1516
+ config.mode = mode;
1517
+ config.agentHostAlias = agentHostAlias;
1518
+ config.frontendKind = useStaticMode ? "static" : "vite";
1519
+ ensureDirectories(config);
1520
+ if (typeof extraPrereqs === "function") {
1521
+ extraPrereqs(config);
1522
+ }
1523
+
1524
+ if (
1525
+ config.launchFrontend &&
1526
+ useStaticMode &&
1527
+ typeof buildStaticFrontend === "function"
1528
+ ) {
1529
+ buildStaticFrontend(config, args);
1530
+ }
1531
+
1532
+ // In static mode, verify build exists after any launcher-managed build.
1533
+ if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) {
1534
+ logError(`Static directory not found: ${staticDir}`);
1535
+ logError(`Run 'npm run build' first to create the static files.`);
1536
+ process.exit(1);
1537
+ }
1538
+
1539
+ // Start services phase
1540
+ logStep("2/2", "Starting services...");
1541
+
1542
+ let agentServerReady = false;
1543
+
1544
+ // 1. Start agent-server first (automation depends on it).
1545
+ //
1546
+ // Readiness timeout defaults to 60 s, which is fine for `npm run dev` against
1547
+ // a warm uvx cache. The Electron desktop launcher overrides this via the
1548
+ // `agentServerReadyTimeoutMs` option because first-launch on a fresh machine
1549
+ // runs `uvx` to download Python + install `openhands-agent-server` from PyPI,
1550
+ // which can take several minutes. Dropping the user into a half-booted UI
1551
+ // before that completes triggers axios "Request timeout" popups on the first
1552
+ // SPA fetch that hits an unbound port 18000.
1553
+ if (config.launchAgentServer) {
1554
+ const agentServerStarter = startAgentServerOverride ?? startAgentServer;
1555
+ agentServerStarter(config);
1556
+
1557
+ agentServerReady = await waitForService(
1558
+ "agent-server",
1559
+ `${getAgentServerBaseUrl(config)}/server_info`,
1560
+ agentServerReadyTimeoutMs,
1561
+ );
1562
+ }
1563
+
1564
+ // 2. Seed automation API key into agent-server secrets
1565
+ // This makes the key available to agents during conversations
1566
+ // Note: seedAutomationSecret has its own retry logic if server is still warming up
1567
+ if (config.launchAutomation && agentServerReady) {
1568
+ await seedAutomationSecret(config);
1569
+ } else if (config.launchAutomation) {
1570
+ logService(
1571
+ "secrets",
1572
+ "Skipping secret seeding - agent-server not ready",
1573
+ c.yellow,
1574
+ );
1575
+ }
1576
+
1577
+ // 3. Start automation backend
1578
+ if (config.launchAutomation) {
1579
+ startAutomationBackend(config);
1580
+ }
1581
+
1582
+ // 4. Start frontend server (Vite dev server OR static server)
1583
+ if (config.launchFrontend) {
1584
+ if (useStaticMode) {
1585
+ startStaticFrontend(config, staticDir);
1586
+ } else {
1587
+ startVite(config);
1588
+ }
1589
+ }
1590
+
1591
+ // 5. Wait for services to be ready
1592
+ await delay(2000);
1593
+
1594
+ // 6. Start ingress proxy (routes traffic only to running services)
1595
+ startIngress(config);
1596
+
1597
+ // Wait for ingress to start
1598
+ await delay(1000);
1599
+
1600
+ printBanner(config);
1601
+
1602
+ // Return the resolved config + readiness signal so embedded launchers can
1603
+ // (a) build URLs from the actual allocated ports and (b) decide whether to
1604
+ // show an error to the user when the agent-server never came up.
1605
+ return { config, agentServerReady };
1606
+ }
1607
+
1608
+ function startStaticFrontend(config, staticDir) {
1609
+ logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
1610
+ logService("static", `Serving from: ${staticDir}`, c.dim);
1611
+
1612
+ // Build the runtime-services info JSON so static-server can append it to
1613
+ // /server_info. The static-server also injects the old window global for
1614
+ // compatibility with previously built frontend bundles.
1615
+ const runtimeServicesInfo = config.launchAgentServer
1616
+ ? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
1617
+ : null;
1618
+
1619
+ const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
1620
+ spawnService(
1621
+ "static",
1622
+ "node",
1623
+ [
1624
+ staticServerScript,
1625
+ "--dir",
1626
+ staticDir,
1627
+ "--port",
1628
+ String(config.vitePort),
1629
+ ...(process.env.VITE_BASE_PATH
1630
+ ? ["--base-path", process.env.VITE_BASE_PATH]
1631
+ : []),
1632
+ // In local mode, inject the API key so the pre-built frontend can
1633
+ // authenticate transparently. In public mode, pass --auth-required
1634
+ // so the frontend shows the API key entry screen instead.
1635
+ ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey
1636
+ ? ["--session-api-key", config.sessionApiKey]
1637
+ : []),
1638
+ ...(config.launchAgentServer && config.isPublic
1639
+ ? ["--auth-required"]
1640
+ : []),
1641
+ // Inject runtime-services info so the agent knows what's reachable.
1642
+ ...(runtimeServicesInfo
1643
+ ? ["--runtime-services-info", runtimeServicesInfo]
1644
+ : []),
1645
+ // Proxy routes only to services that this launch mode started.
1646
+ ...buildRouteArgs(getLocalServiceRoutes(config)),
1647
+ // Only the static server injects into the document, so only it can tell
1648
+ // the frontend this origin serves the editor. The ingress routes the same
1649
+ // prefix but proxies the HTML through untouched.
1650
+ ...getVSCodeAdvertiseArgs(config),
1651
+ ...getNoReferrerPrefixArgs(config),
1652
+ // Reject known API prefixes that have no backend β€” returns 503
1653
+ // instead of SPA-fallbacking to index.html.
1654
+ ...buildRejectPrefixArgs(getRejectPrefixes(config)),
1655
+ ],
1656
+ {
1657
+ cwd: config.canvasPath,
1658
+ color: c.magenta,
1659
+ },
1660
+ );
1661
+ }
1662
+
1663
+ // ═══════════════════════════════════════════════════════════════════════════
1664
+ // Exports for testing
1665
+ // ═══════════════════════════════════════════════════════════════════════════
1666
+
1667
+ export {
1668
+ buildAgentServerAutomationEnv,
1669
+ buildAutomationCommand,
1670
+ buildAutomationTelemetryEnv,
1671
+ buildConfig,
1672
+ buildRouteArgs,
1673
+ buildViteBackendEnv,
1674
+ getAgentServerBaseUrl,
1675
+ getFrontendBackend,
1676
+ getLocalServiceRoutes,
1677
+ getNoReferrerPrefixArgs,
1678
+ getRejectPrefixes,
1679
+ getVSCodeAdvertiseArgs,
1680
+ main,
1681
+ registerShutdownHook,
1682
+ spawnService,
1683
+ commandExists,
1684
+ validateLocalAutomationPath,
1685
+ logService,
1686
+ logStep,
1687
+ logSuccess,
1688
+ logError,
1689
+ c,
1690
+ DEFAULT_AUTOMATION_REPO,
1691
+ DEFAULT_AUTOMATION_PACKAGE,
1692
+ DEFAULT_AUTOMATION_VERSION,
1693
+ DEFAULT_AUTOMATION_SDK_VERSION,
1694
+ DEFAULT_BACKEND_PORT,
1695
+ DEFAULT_AUTOMATION_PORT,
1696
+ };
1697
+
1698
+ // ═══════════════════════════════════════════════════════════════════════════
1699
+ // Main entry point (only when run directly, not when imported)
1700
+ // ═══════════════════════════════════════════════════════════════════════════
1701
+
1702
+ // Check if this module is the main entry point
1703
+ const isMainModule =
1704
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1705
+
1706
+ if (isMainModule) {
1707
+ main().catch((err) => {
1708
+ logError(`Fatal error: ${err.message}`);
1709
+ if (err.stack) {
1710
+ console.error(c.dim + err.stack + c.reset);
1711
+ fileLog("error", err.stack);
1712
+ }
1713
+ process.exit(1);
1714
+ });
1715
+ }
scripts/docker-build.mjs ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local Docker build helper.
4
+ *
5
+ * Reads version pins from config/defaults.json and invokes `docker build`
6
+ * with the correct --build-arg values so developers never need to remember
7
+ * (or hardcode) version strings.
8
+ *
9
+ * Usage:
10
+ * node scripts/docker-build.mjs # defaults
11
+ * node scripts/docker-build.mjs --tag my-tag # custom tag
12
+ * node scripts/docker-build.mjs -- --no-cache # extra docker args
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const projectRoot = join(__dirname, "..");
21
+
22
+ const config = JSON.parse(
23
+ readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
24
+ );
25
+
26
+ const agentServerImage = `${config.images.agentServer}:${config.versions.agentServer}-python`;
27
+ const automationVersion = config.versions.automation;
28
+ const canvasBasePath = config.paths.canvasBasePath;
29
+
30
+ // Parse CLI: --tag <name> and everything after -- is passed to docker build
31
+ let tag = "agent-canvas:local";
32
+ const extraArgs = [];
33
+ const args = process.argv.slice(2);
34
+ for (let i = 0; i < args.length; i++) {
35
+ if (args[i] === "--tag" && i + 1 < args.length) {
36
+ tag = args[++i];
37
+ } else if (args[i] === "--") {
38
+ extraArgs.push(...args.slice(i + 1));
39
+ break;
40
+ } else {
41
+ extraArgs.push(args[i]);
42
+ }
43
+ }
44
+
45
+ const cmd = [
46
+ "docker",
47
+ "build",
48
+ "-f",
49
+ "docker/Dockerfile",
50
+ "--build-arg",
51
+ `AGENT_SERVER_IMAGE=${agentServerImage}`,
52
+ "--build-arg",
53
+ `AUTOMATION_VERSION=${automationVersion}`,
54
+ "--build-arg",
55
+ `VITE_BASE_PATH=${canvasBasePath}`,
56
+ "-t",
57
+ tag,
58
+ ...extraArgs,
59
+ ".",
60
+ ];
61
+
62
+ console.log(`Agent Server image : ${agentServerImage}`);
63
+ console.log(`Automation version : ${automationVersion}`);
64
+ console.log(`Canvas base path : ${canvasBasePath}`);
65
+ console.log(`Tag : ${tag}`);
66
+ console.log(`\n$ ${cmd.join(" ")}\n`);
67
+
68
+ try {
69
+ execFileSync(cmd[0], cmd.slice(1), {
70
+ cwd: projectRoot,
71
+ stdio: "inherit",
72
+ });
73
+ } catch (err) {
74
+ process.exit(err.status || 1);
75
+ }
scripts/download-node.mjs ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Download the official Node.js distribution for the current platform into
4
+ * resources/node/, so electron-builder can bundle it as an extraResource.
5
+ *
6
+ * The packaged Electron desktop app uses this bundled Node to provide
7
+ * `node`, `npm`, and `npx` to spawned subprocesses β€” most importantly the
8
+ * stdio MCP servers in the marketplace (Slack, GitHub, Figma, etc.) whose
9
+ * commands start with `npx -y <package>`.
10
+ *
11
+ * Why bundle Node instead of using Electron-as-Node (ELECTRON_RUN_AS_NODE=1)?
12
+ *
13
+ * We tried that first. Electron-as-Node works fine for our backend
14
+ * helper scripts (static-server.mjs, ingress.mjs) which mostly do
15
+ * networking, but it is **not** reliable for stdio JSON-RPC servers.
16
+ * When npx-cli.js (running under Electron-as-Node) spawned the MCP
17
+ * server, the child's stdin pipe semantics differed from vanilla Node
18
+ * on macOS (the parent is a windowed Electron process, not a clean
19
+ * command-line Node binary) β€” the server appeared to start, then
20
+ * immediately exited with "McpError: Connection closed" before the
21
+ * first JSON-RPC handshake message could land. Bundling the real
22
+ * Node binary sidesteps all of that.
23
+ *
24
+ * The downloaded Node.js distribution already includes npm and npx at
25
+ * `bin/npm` / `bin/npx` (POSIX) or `npm.cmd` / `npx.cmd` (Windows), so we
26
+ * do **not** need a separate npm download (this script supersedes the
27
+ * earlier download-npm.mjs).
28
+ *
29
+ * Usage:
30
+ * node scripts/download-node.mjs # uses NODE_BUNDLE_VERSION below
31
+ * NODE_VERSION=22.10.0 node scripts/download-node.mjs
32
+ *
33
+ * Output (per platform):
34
+ * POSIX: resources/node/bin/{node,npm,npx} + resources/node/lib/node_modules/npm/...
35
+ * Windows: resources/node/{node.exe,npm.cmd,npx.cmd} + resources/node/node_modules/npm/...
36
+ */
37
+
38
+ import {
39
+ chmodSync,
40
+ createWriteStream,
41
+ existsSync,
42
+ lstatSync,
43
+ mkdirSync,
44
+ readdirSync,
45
+ readlinkSync,
46
+ rmSync,
47
+ statSync,
48
+ unlinkSync,
49
+ } from "node:fs";
50
+ import { get } from "node:https";
51
+ import { tmpdir } from "node:os";
52
+ import { dirname, join } from "node:path";
53
+ import { fileURLToPath } from "node:url";
54
+ import { execFileSync } from "node:child_process";
55
+
56
+ const __dirname = dirname(fileURLToPath(import.meta.url));
57
+ const projectRoot = join(__dirname, "..");
58
+ const outDir = join(projectRoot, "resources", "node");
59
+
60
+ // Pinned Node version. Electron 42 ships Node 22, so we bundle a 22.x
61
+ // LTS release to match the embedded runtime's ABI/native-module surface.
62
+ // We intentionally use 22.12.0 β€” the repo's own support floor
63
+ // (package.json engines.node >=22.12.0, volta 22.12.0) β€” rather than
64
+ // Electron 42.3.2's exact embedded Node patch level: the bundled binary
65
+ // runs this repo's launcher scripts, and native modules only need ABI
66
+ // parity (NODE_MODULE_VERSION 127, shared by all 22.x builds).
67
+ // Override at build time with NODE_VERSION=… (e.g. to test against a
68
+ // newer release). Major version >=22 only; engines.node in npm 10.x
69
+ // requires ^18.17.0 || >=20.5.0.
70
+ const NODE_BUNDLE_VERSION = "22.12.0";
71
+
72
+ // ── Platform detection ───────────────────────────────────────────────────────
73
+
74
+ const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32'
75
+ const ARCH = process.arch; // 'x64' | 'arm64' | 'ia32'
76
+
77
+ /**
78
+ * Map (platform, arch) β†’ Node's published distribution name.
79
+ * Names come straight from https://nodejs.org/dist/<version>/.
80
+ *
81
+ * macOS arm64 β†’ node-v<ver>-darwin-arm64.tar.gz
82
+ * macOS x64 β†’ node-v<ver>-darwin-x64.tar.gz
83
+ * linux x64 β†’ node-v<ver>-linux-x64.tar.gz
84
+ * linux arm64 β†’ node-v<ver>-linux-arm64.tar.gz
85
+ * win32 x64 β†’ node-v<ver>-win-x64.zip
86
+ * win32 arm64 β†’ node-v<ver>-win-arm64.zip
87
+ */
88
+ function getPlatformSpec(version) {
89
+ const base = `node-v${version}`;
90
+ if (PLATFORM === "darwin") {
91
+ const arch = ARCH === "arm64" ? "arm64" : "x64";
92
+ return { name: `${base}-darwin-${arch}`, ext: "tar.gz" };
93
+ }
94
+ if (PLATFORM === "linux") {
95
+ const arch = ARCH === "arm64" ? "arm64" : "x64";
96
+ return { name: `${base}-linux-${arch}`, ext: "tar.gz" };
97
+ }
98
+ if (PLATFORM === "win32") {
99
+ const arch = ARCH === "arm64" ? "arm64" : "x64";
100
+ return { name: `${base}-win-${arch}`, ext: "zip" };
101
+ }
102
+ throw new Error(`Unsupported platform for Node download: ${PLATFORM}/${ARCH}`);
103
+ }
104
+
105
+ // ── Version resolution ───────────────────────────────────────────────────────
106
+
107
+ function resolveVersion() {
108
+ const requested = process.env.NODE_VERSION?.replace(/^v/, "");
109
+ return requested || NODE_BUNDLE_VERSION;
110
+ }
111
+
112
+ // ── HTTP helpers ─────────────────────────────────────────────────────────────
113
+
114
+ function downloadFile(url, dest) {
115
+ return new Promise((resolve, reject) => {
116
+ const file = createWriteStream(dest);
117
+ function doGet(u) {
118
+ get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => {
119
+ if (res.statusCode === 301 || res.statusCode === 302) {
120
+ return doGet(res.headers.location);
121
+ }
122
+ if (res.statusCode !== 200) {
123
+ file.destroy();
124
+ return reject(new Error(`GET ${u} β†’ HTTP ${res.statusCode}`));
125
+ }
126
+ res.pipe(file);
127
+ file.on("finish", () => file.close(resolve));
128
+ file.on("error", reject);
129
+ res.on("error", reject);
130
+ }).on("error", (err) => {
131
+ file.destroy();
132
+ reject(err);
133
+ });
134
+ }
135
+ doGet(url);
136
+ });
137
+ }
138
+
139
+ // ── Extraction ───────────────────────────────────────────────────────────────
140
+
141
+ function extract(archivePath, targetDir, ext) {
142
+ // Both tar.gz and zip extract via system `tar`:
143
+ // GNU/BSD tar (macOS/Linux) handles .tar.gz natively.
144
+ // bsdtar (Windows 10+) handles both .tar.gz and .zip.
145
+ // --strip-components=1 drops the "node-vX.Y.Z-<platform>-<arch>/" top dir.
146
+ void ext; // archive content is identified by tar's own magic bytes
147
+ execFileSync(
148
+ "tar",
149
+ ["-xf", archivePath, "-C", targetDir, "--strip-components=1"],
150
+ { stdio: "inherit" },
151
+ );
152
+ }
153
+
154
+ function ensureExecutable(p) {
155
+ if (process.platform === "win32") return;
156
+ try {
157
+ chmodSync(p, 0o755);
158
+ } catch {}
159
+ }
160
+
161
+ // ── Layout verification ──────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Confirm the extracted tree has the binaries we depend on.
165
+ * On Unix Node puts them in bin/; on Windows they live at the root.
166
+ */
167
+ function verifyLayout() {
168
+ const isWin = PLATFORM === "win32";
169
+ const required = isWin
170
+ ? ["node.exe", "npm.cmd", "npx.cmd"]
171
+ : ["bin/node", "bin/npm", "bin/npx"];
172
+ for (const rel of required) {
173
+ const p = join(outDir, rel);
174
+ if (!existsSync(p)) {
175
+ throw new Error(
176
+ `Expected ${rel} in extracted Node distribution but it is missing ` +
177
+ `at ${p}. Did the tarball layout change?`,
178
+ );
179
+ }
180
+ ensureExecutable(p);
181
+ }
182
+
183
+ // npm/npx are wrapper scripts that invoke node against npm's JS entry
184
+ // points; verify the targets exist too so a packaged build doesn't ship
185
+ // a half-broken installation.
186
+ const npmCli = isWin
187
+ ? join(outDir, "node_modules", "npm", "bin", "npm-cli.js")
188
+ : join(outDir, "lib", "node_modules", "npm", "bin", "npm-cli.js");
189
+ if (!existsSync(npmCli)) {
190
+ throw new Error(`Bundled Node is missing npm-cli.js at ${npmCli}`);
191
+ }
192
+ }
193
+
194
+ // ── Pruning ──────────────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Drop pieces of the Node distribution that are only useful when building
198
+ * native modules from source or for human-readable documentation. Stripping
199
+ * these shrinks the bundled Node from ~170 MB β†’ ~115 MB on Linux x64 (the
200
+ * Node binary itself is the bulk of what remains and can't be reduced).
201
+ *
202
+ * Kept intentionally:
203
+ * bin/node, bin/npm, bin/npx β€” runtime binaries / wrappers
204
+ * lib/node_modules/{npm,corepack} β€” npm itself
205
+ * LICENSE β€” required by the BSD-style Node license
206
+ */
207
+ function pruneUnusedFiles() {
208
+ // IMPORTANT: every entry that points into a directory we delete must also
209
+ // delete any symlink/shim that targets into it, otherwise electron-builder
210
+ // hits ENOENT trying to stat() the dangling symlink while copying the
211
+ // extraResource into the .app bundle.
212
+ //
213
+ // Example: Node's POSIX tarball ships `bin/corepack` as a symlink to
214
+ // `../lib/node_modules/corepack/dist/corepack.js`. If we drop the corepack
215
+ // module under lib/ but leave the symlink, `electron-builder` fails with
216
+ // ENOENT: ... Resources/node/bin/corepack
217
+ const candidates =
218
+ PLATFORM === "win32"
219
+ ? [
220
+ // Windows Node zip lays out npm directly under node_modules/, not lib/.
221
+ // Strip docs, headers, and node_modules/corepack (the npm runtime
222
+ // doesn't need corepack to run, and we don't ship yarn/pnpm).
223
+ "CHANGELOG.md",
224
+ "README.md",
225
+ "node_modules/corepack",
226
+ // Windows ships corepack as both a Bash wrapper and a cmd.exe wrapper
227
+ // at the distribution root; both proxy into node_modules/corepack.
228
+ "corepack",
229
+ "corepack.cmd",
230
+ ]
231
+ : [
232
+ // POSIX layout β€” keep bin/ and lib/node_modules/npm; drop the rest.
233
+ "include",
234
+ "share",
235
+ "CHANGELOG.md",
236
+ "README.md",
237
+ "lib/node_modules/corepack",
238
+ // Symlink in bin/ targets the corepack we just deleted.
239
+ "bin/corepack",
240
+ ];
241
+ for (const rel of candidates) {
242
+ const p = join(outDir, rel);
243
+ // `rmSync(force: true)` resolves the path through symlinks, so once the
244
+ // corepack target directory is deleted the now-dangling `bin/corepack`
245
+ // link reads as "already gone" and silently survives β€” the exact ENOENT
246
+ // trap failOnDanglingSymlinks() exists to catch. Remove files/symlinks
247
+ // with `unlinkSync` (lstat semantics, works on dangling links) first and
248
+ // fall back to `rmSync` for directories.
249
+ try {
250
+ unlinkSync(p);
251
+ } catch {
252
+ try {
253
+ rmSync(p, { recursive: true, force: true });
254
+ } catch {
255
+ // best-effort
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ // ── Dangling-symlink check ───────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Walk the pruned tree and refuse to finish if any symlink points at a path
265
+ * that no longer exists. electron-builder calls `stat()` (which follows
266
+ * symlinks) on every entry it copies into the .app bundle, so a single
267
+ * dangling symlink blows up the whole `build:desktop` step with a confusing
268
+ * ENOENT β€” fail at download time instead, with a message that says which
269
+ * pruned directory the symlink was reaching into.
270
+ */
271
+ function failOnDanglingSymlinks() {
272
+ const broken = [];
273
+ const stack = [outDir];
274
+ while (stack.length) {
275
+ const next = stack.pop();
276
+ let entries;
277
+ try {
278
+ entries = readdirSync(next, { withFileTypes: true });
279
+ } catch {
280
+ continue;
281
+ }
282
+ for (const entry of entries) {
283
+ const p = join(next, entry.name);
284
+ if (entry.isSymbolicLink()) {
285
+ try {
286
+ // statSync follows the link; if the target is gone this throws.
287
+ statSync(p);
288
+ } catch {
289
+ let target = "<unreadable>";
290
+ try {
291
+ if (lstatSync(p).isSymbolicLink()) target = readlinkSync(p);
292
+ } catch {
293
+ // ignore β€” best-effort labelling
294
+ }
295
+ broken.push(`${p} β†’ ${target}`);
296
+ }
297
+ } else if (entry.isDirectory()) {
298
+ stack.push(p);
299
+ }
300
+ }
301
+ }
302
+ if (broken.length) {
303
+ console.error(
304
+ "[download-node] Dangling symlinks remain after pruning β€” these would " +
305
+ "crash electron-builder later with ENOENT. Add the dangling symlink " +
306
+ "(or its target) to pruneUnusedFiles() in this script:",
307
+ );
308
+ for (const entry of broken) console.error(" β€’", entry);
309
+ throw new Error(`${broken.length} dangling symlink(s) in resources/node/`);
310
+ }
311
+ }
312
+
313
+ // ── Size report ──────────────────────────────────────────────────────────────
314
+
315
+ function dirSizeBytes(dir) {
316
+ let total = 0;
317
+ const stack = [dir];
318
+ while (stack.length) {
319
+ const next = stack.pop();
320
+ let entries;
321
+ try {
322
+ entries = readdirSync(next, { withFileTypes: true });
323
+ } catch {
324
+ continue;
325
+ }
326
+ for (const entry of entries) {
327
+ const p = join(next, entry.name);
328
+ if (entry.isDirectory()) {
329
+ stack.push(p);
330
+ } else {
331
+ try {
332
+ total += statSync(p).size;
333
+ } catch {}
334
+ }
335
+ }
336
+ }
337
+ return total;
338
+ }
339
+
340
+ // ── Main ─────────────────────────────────────────────────────────────────────
341
+
342
+ async function main() {
343
+ const version = resolveVersion();
344
+ const spec = getPlatformSpec(version);
345
+ const archiveName = `${spec.name}.${spec.ext}`;
346
+ const url = `https://nodejs.org/dist/v${version}/${archiveName}`;
347
+ const tmpFile = join(tmpdir(), `node-download-${Date.now()}.${spec.ext}`);
348
+
349
+ console.log(
350
+ `[download-node] Downloading Node v${version} for ${PLATFORM}/${ARCH}`,
351
+ );
352
+ console.log(`[download-node] URL: ${url}`);
353
+
354
+ try {
355
+ // Clear any previous output so stale files (different Node version, or
356
+ // a stale resources/npm/ from the previous wrapper approach) don't
357
+ // linger in the bundle.
358
+ if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
359
+ mkdirSync(outDir, { recursive: true });
360
+
361
+ console.log(`[download-node] Downloading to ${tmpFile}`);
362
+ await downloadFile(url, tmpFile);
363
+ console.log(`[download-node] Extracting to ${outDir}`);
364
+ extract(tmpFile, outDir, spec.ext);
365
+
366
+ verifyLayout();
367
+ pruneUnusedFiles();
368
+ failOnDanglingSymlinks();
369
+
370
+ const mb = Math.round(dirSizeBytes(outDir) / (1024 * 1024));
371
+ console.log(`[download-node] βœ“ Node v${version} ready at ${outDir} (~${mb} MB)`);
372
+ } finally {
373
+ try {
374
+ rmSync(tmpFile, { force: true });
375
+ } catch {}
376
+ }
377
+ }
378
+
379
+ main().catch((err) => {
380
+ console.error("[download-node] Error:", err.message);
381
+ process.exit(1);
382
+ });