Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .github/dependabot.yml +110 -0
- .github/pull_request_template.md +65 -0
- .github/release.yml +14 -0
- __tests__/MSW.md +134 -0
- __tests__/agent-server-ui-providers.test.tsx +279 -0
- __tests__/agent-server-ui-style-scope.test.ts +52 -0
- __tests__/build-websocket-url.test.ts +269 -0
- __tests__/conversation-local-storage.test.ts +692 -0
- __tests__/initial-query.test.tsx +24 -0
- __tests__/library-entrypoints.test.ts +49 -0
- __tests__/package-library.test.ts +158 -0
- __tests__/query-client-config.behavior.test.ts +522 -0
- __tests__/query-client-config.test.ts +92 -0
- __tests__/root.test.tsx +926 -0
- __tests__/router.md +227 -0
- __tests__/settings-schema-descriptions.test.ts +21 -0
- __tests__/vite-config.test.ts +98 -0
- __tests__/vitest-setup-progress-event.test.ts +62 -0
- docs/ACP_AGENTS.md +239 -0
- docs/CANVAS_EXTENSIONS_TESTING.md +93 -0
- docs/DEVELOPMENT.md +205 -0
- docs/DefenseClaw.md +303 -0
- docs/README.md +11 -0
- electron/loading.html +359 -0
- electron/main.mjs +785 -0
- electron/package.json +7 -0
- electron/preload.cjs +31 -0
- public/android-chrome-192x192.png +0 -0
- public/android-chrome-512x512.png +0 -0
- public/apple-touch-icon.png +0 -0
- public/browserconfig.xml +9 -0
- public/favicon-16x16.png +0 -0
- public/favicon-32x32.png +0 -0
- public/favicon.ico +0 -0
- public/favicon.svg +1 -0
- public/mockServiceWorker.js +361 -0
- public/mstile-150x150.png +0 -0
- public/robots.txt +3 -0
- public/safari-pinned-tab.svg +7 -0
- public/site.webmanifest +19 -0
- scripts/brand-dev-electron.mjs +244 -0
- scripts/check-sdk-version-sync.mjs +500 -0
- scripts/check-translation-completeness.cjs +200 -0
- scripts/dev-extra-backend.mjs +262 -0
- scripts/dev-process-utils.mjs +150 -0
- scripts/dev-safe.mjs +1217 -0
- scripts/dev-static.mjs +665 -0
- scripts/dev-with-automation.mjs +1715 -0
- scripts/docker-build.mjs +75 -0
- 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 |
+
});
|