From af495af3deaaf69eeed8241956b8476db321c081 Mon Sep 17 00:00:00 2001 From: hieptl Date: Fri, 24 Jul 2026 13:10:08 +0700 Subject: [PATCH 1/3] feat: add VITE_DISABLE_ONBOARDING to suppress the onboarding wizard --- .env.sample | 1 + AGENTS.md | 1 + __tests__/api/agent-server-config.test.ts | 34 ++++++++++++++ .../onboarding/onboarding-host.test.tsx | 24 ++++++++++ __tests__/root.test.tsx | 17 +++++++ __tests__/scripts/static-server.test.ts | 44 +++++++++++++++++++ docs/DEVELOPMENT.md | 1 + scripts/static-server.mjs | 31 +++++++++++++ src/api/agent-server-config.ts | 17 +++++++ .../onboarding/use-onboarding-completion.ts | 10 ++++- 10 files changed, 179 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 0ad49b905..c2bc925cb 100644 --- a/.env.sample +++ b/.env.sample @@ -17,6 +17,7 @@ VITE_FRONTEND_PORT="3001" # Port to run the frontend application VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests # VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development. +# VITE_DISABLE_ONBOARDING="true" # Skip the first-run onboarding wizard. Runtime equivalent for prebuilt bundles: static-server.mjs --disable-onboarding (or env AGENT_CANVAS_DISABLE_ONBOARDING=true). # Public PostHog client key compiled into source builds. Precompiled library # consumers can instead pass apiKey/apiHost/uiHost to AgentServerUIProviders. diff --git a/AGENTS.md b/AGENTS.md index abe576e29..75aa9034a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ - `VITE_WORKING_DIR` for the default workspace path sent when starting conversations. - `VITE_ENABLE_BROWSER_TOOLS=false` to omit `BrowserToolSet` from new conversation payloads. - `VITE_BASE_PATH` for serving the SPA under a subpath such as `/canvas`; pair it with `scripts/static-server.mjs --base-path` at runtime. + - `VITE_DISABLE_ONBOARDING=true` to skip the first-run onboarding wizard (users land directly on the home screen; Cloud login is never suppressed); pair it with `scripts/static-server.mjs --disable-onboarding` or env `AGENT_CANVAS_DISABLE_ONBOARDING=true` at runtime for prebuilt bundles. - Public skills are loaded from the `@openhands/extensions` npm package at build time via `SKILLS_CATALOG` (exported from `@openhands/extensions/skills`). The frontend's `SkillsService` maps catalog entries to `SkillInfo` objects and merges them with user/project skills fetched from the agent-server (with `load_public: false`). The agent-server no longer clones the extensions repo or uses `EXTENSIONS_REF` for public skills. - Default working-dir fallback is now the relative path `workspace/project` (exported as `DEFAULT_WORKING_DIR` from `src/api/agent-server-config.ts`); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding `/workspace/project`. - Current Cloud behavior is implemented explicitly through the backend registry, Cloud service layer, and device authorization flow. diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts index 21a3b7fd3..aa0debd4b 100644 --- a/__tests__/api/agent-server-config.test.ts +++ b/__tests__/api/agent-server-config.test.ts @@ -12,6 +12,7 @@ import { isAuthRequired, isSameCloudHost, isAuthRequiredAndMissing, + isOnboardingDisabled, } from "#/api/agent-server-config"; const ORIGINAL_LOCATION = window.location; @@ -247,6 +248,39 @@ describe("isAuthRequiredAndMissing", () => { }); }); +describe("isOnboardingDisabled", () => { + afterEach(() => { + delete (window as unknown as Record) + .__AGENT_CANVAS_DISABLE_ONBOARDING__; + }); + + it("returns false when neither env var nor window flag is set", () => { + expect(isOnboardingDisabled()).toBe(false); + }); + + it("returns true when VITE_DISABLE_ONBOARDING is 'true'", () => { + vi.stubEnv("VITE_DISABLE_ONBOARDING", "true"); + + expect(isOnboardingDisabled()).toBe(true); + }); + + it("returns true when window.__AGENT_CANVAS_DISABLE_ONBOARDING__ is set", () => { + ( + window as unknown as Record + ).__AGENT_CANVAS_DISABLE_ONBOARDING__ = true; + + expect(isOnboardingDisabled()).toBe(true); + }); + + it("returns false when window flag is a non-true value", () => { + ( + window as unknown as Record + ).__AGENT_CANVAS_DISABLE_ONBOARDING__ = "true"; + + expect(isOnboardingDisabled()).toBe(false); + }); +}); + // Covers the published `agent-canvas` binary path: the prebuilt bundle has // no VITE_SESSION_API_KEY baked in, but `scripts/static-server.mjs` injects // the runtime key into `window.__AGENT_CANVAS_SESSION_API_KEY__`. Without diff --git a/__tests__/components/onboarding/onboarding-host.test.tsx b/__tests__/components/onboarding/onboarding-host.test.tsx index 790f89205..f36917be4 100644 --- a/__tests__/components/onboarding/onboarding-host.test.tsx +++ b/__tests__/components/onboarding/onboarding-host.test.tsx @@ -243,4 +243,28 @@ describe("OnboardingHost", () => { window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), ).toBeNull(); }); + + it("does not mount the modal for a fresh Cloud user when onboarding is disabled for the deployment", () => { + vi.stubEnv("VITE_DISABLE_ONBOARDING", "true"); + seedCloudBackend(); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ + ...DEFAULT_SETTINGS, + llm_api_key_set: true, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { model: "openhands/minimax-m2.7", api_key: "stored" }, + }, + }); + + renderHost(); + + expect( + screen.queryByTestId("onboarding-modal-stub"), + ).not.toBeInTheDocument(); + // Suppression comes from the deployment flag, not a fake completion + // marker — nothing is written to localStorage. + expect( + window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), + ).toBeNull(); + }); }); diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx index a4908d28d..e8c1657a3 100644 --- a/__tests__/root.test.tsx +++ b/__tests__/root.test.tsx @@ -192,6 +192,23 @@ describe("App root agent-server availability guard", () => { ).not.toBeInTheDocument(); }); + it("skips first-run onboarding for a fresh install when onboarding is disabled for the deployment", async () => { + vi.stubEnv("VITE_DISABLE_ONBOARDING", "true"); + server.use( + http.get("*/server_info", () => + HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), + ), + ); + + renderApp(["/"]); + + expect(await screen.findByTestId("app-outlet")).toBeInTheDocument(); + expect( + screen.queryByTestId("first-run-onboarding-screen"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); + }); + it("shows first-run onboarding before the recovery modal when locked to Cloud with no backend", async () => { vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); vi.stubEnv("VITE_SESSION_API_KEY", ""); diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts index 93d755583..aca223cec 100644 --- a/__tests__/scripts/static-server.test.ts +++ b/__tests__/scripts/static-server.test.ts @@ -85,6 +85,16 @@ describe("static-server.mjs", () => { expect(config.lockToCloud).toBeNull(); }); + it("defaults disableOnboarding to false", () => { + const config = parseArgs([]); + expect(config.disableOnboarding).toBe(false); + }); + + it("parses --disable-onboarding", () => { + const config = parseArgs(["--disable-onboarding"]); + expect(config.disableOnboarding).toBe(true); + }); + it("defaults basePath to root", () => { const config = parseArgs([]); expect(config.basePath).toBe("/"); @@ -240,6 +250,40 @@ describe("static-server.mjs", () => { }); }); + describe("disable-onboarding injection", () => { + // Covers the SaaS / prebuilt-bundle path: the published build has no + // VITE_DISABLE_ONBOARDING baked in, so the wizard is suppressed through + // this injected window global (see `isOnboardingDisabled()` in + // src/api/agent-server-config.ts). + it("exposes the flag on window.__AGENT_CANVAS_DISABLE_ONBOARDING__", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const origin = await startServer(buildDir, { disableOnboarding: true }); + const body = await (await fetch(`${origin}/`)).text(); + + expect(body).toContain("window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true"); + }); + + it("does not inject the flag by default", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const origin = await startServer(buildDir); + const body = await (await fetch(`${origin}/`)).text(); + + expect(body).not.toContain("__AGENT_CANVAS_DISABLE_ONBOARDING__"); + }); + }); + describe("session key injection", () => { async function startServerWithKey(dir: string, sessionApiKey: string) { const server = await startStaticServer({ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 77882451e..9409f6de1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -162,6 +162,7 @@ You can create a `.env` file in the project directory with these variables based | `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` | | `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` | | `VITE_BASE_PATH` | Build/serve the SPA under a subpath such as `/canvas` | `/` | +| `VITE_DISABLE_ONBOARDING` | Set to `true` to skip the first-run onboarding wizard (users land on the home screen) | `false` | | `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` | | `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` | | `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` | diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index 4feb41c06..0d44c1e59 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -86,6 +86,7 @@ export function parseArgs(argv = process.argv.slice(2)) { authRequired: false, runtimeServicesInfo: null, lockToCloud: null, + disableOnboarding: false, basePath: "/", }; @@ -128,6 +129,9 @@ export function parseArgs(argv = process.argv.slice(2)) { case "--lock-to-cloud": config.lockToCloud = argv[++i] || null; break; + case "--disable-onboarding": + config.disableOnboarding = true; + break; case "--base-path": config.basePath = normalizeBasePath(argv[++i]); break; @@ -206,6 +210,10 @@ OPTIONS: --lock-to-cloud Lock backend setup to a single OpenHands Cloud URL. Hides manual/local backend setup and the custom Cloud URL field in the pre-built frontend. + --disable-onboarding Inject a flag into index.html so the pre-built + frontend skips the first-run onboarding wizard. + Also enabled by env + AGENT_CANVAS_DISABLE_ONBOARDING=true. --base-path Mount the SPA under (default: /). For example, --base-path /canvas serves index.html and assets under /canvas. @@ -263,6 +271,11 @@ ROUTING: * `agent-server-config.ts` so pre-built frontend bundles can hide manual * backend setup and the custom Cloud URL field at runtime. * + * - `disableOnboarding`: sets `window.__AGENT_CANVAS_DISABLE_ONBOARDING__ = + * true` so the pre-built frontend skips the first-run onboarding wizard + * (read by `isOnboardingDisabled()` in `agent-server-config.ts`) without + * VITE_DISABLE_ONBOARDING baked in. + * * - `basePath`: the path prefix the SPA is mounted under, exposed as * `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale * files can resolve through the same subpath as the built bundle. @@ -272,6 +285,7 @@ function makeConfigInjectionScript( authRequired, runtimeServicesInfo, lockToCloud, + disableOnboarding, basePath, ) { const parts = []; @@ -316,6 +330,10 @@ function makeConfigInjectionScript( ); } + if (disableOnboarding) { + parts.push(`window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true;`); + } + if (basePath && basePath !== "/") { parts.push( `window.__AGENT_CANVAS_BASE_PATH__=${JSON.stringify(basePath)};`, @@ -340,6 +358,7 @@ async function serveInjectedIndexHtml( authRequired, runtimeServicesInfo, lockToCloud, + disableOnboarding, basePath, } = {}, ) { @@ -355,6 +374,7 @@ async function serveInjectedIndexHtml( authRequired, runtimeServicesInfo, lockToCloud, + disableOnboarding, basePath, ); // Inject right before so the key is available before any app code runs. @@ -404,6 +424,7 @@ function needsRuntimeInjection(injectionOpts) { injectionOpts.authRequired || injectionOpts.runtimeServicesInfo || injectionOpts.lockToCloud || + injectionOpts.disableOnboarding || (injectionOpts.basePath && injectionOpts.basePath !== "/"), ); } @@ -547,6 +568,7 @@ export function startStaticServer(config) { authRequired: config.authRequired || false, runtimeServicesInfo: config.runtimeServicesInfo || null, lockToCloud: config.lockToCloud || null, + disableOnboarding: config.disableOnboarding || false, basePath: normalizeBasePath(config.basePath), }; const basePath = injectionOpts.basePath; @@ -611,6 +633,9 @@ export function startStaticServer(config) { if (config.lockToCloud) { console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`); } + if (config.disableOnboarding) { + console.log(" First-run onboarding: disabled"); + } console.log(" * (default) -> static files + SPA fallback"); console.log(""); resolveListen(server); @@ -628,6 +653,12 @@ const isMainModule = if (isMainModule) { try { const config = parseArgs(); + // Env fallback so deployments that can only set container env vars + // (e.g. the Helm chart's generic `env:` passthrough) can enable the + // flag without changing the CLI invocation. Env can only turn it ON. + if (process.env.AGENT_CANVAS_DISABLE_ONBOARDING === "true") { + config.disableOnboarding = true; + } await startStaticServer(config); } catch (err) { console.error(err instanceof Error ? err.message : err); diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts index 923c5b352..e6bae71f0 100644 --- a/src/api/agent-server-config.ts +++ b/src/api/agent-server-config.ts @@ -224,3 +224,20 @@ export function isAuthRequiredAndMissing(): boolean { if (!isAuthRequired()) return false; return !getAgentServerSessionApiKey(); } + +/** + * True when first-run onboarding should be suppressed for this deployment + * (users land directly on the home screen with default settings). Sourced + * from build-time `VITE_DISABLE_ONBOARDING=true` or the runtime + * `window.__AGENT_CANVAS_DISABLE_ONBOARDING__` global injected by + * `scripts/static-server.mjs --disable-onboarding`. Cloud login is not + * onboarding and is never suppressed by this flag. + */ +export function isOnboardingDisabled(): boolean { + return ( + import.meta.env.VITE_DISABLE_ONBOARDING === "true" || + (typeof window !== "undefined" && + (window as unknown as Record) + .__AGENT_CANVAS_DISABLE_ONBOARDING__ === true) + ); +} diff --git a/src/components/features/onboarding/use-onboarding-completion.ts b/src/components/features/onboarding/use-onboarding-completion.ts index 3853e1869..ab9a15bee 100644 --- a/src/components/features/onboarding/use-onboarding-completion.ts +++ b/src/components/features/onboarding/use-onboarding-completion.ts @@ -1,5 +1,7 @@ import React from "react"; +import { isOnboardingDisabled } from "#/api/agent-server-config"; + /** * localStorage key persisting whether the welcome onboarding flow has * been completed (or skipped). Once present, the modal won't auto-show @@ -49,5 +51,11 @@ export function useOnboardingCompletion() { setIsCompleted(true); }, []); - return { isCompleted, markCompleted } as const; + // A deployment-level disable flag reports "completed" so both wizard + // gates (root.tsx, onboarding-host.tsx) stay closed; the + // ?previewOnboardingStep harness bypasses isCompleted and keeps working. + return { + isCompleted: isOnboardingDisabled() || isCompleted, + markCompleted, + } as const; } From c1188db81424c590d0ac43fa234522275bb503f0 Mon Sep 17 00:00:00 2001 From: hieptl Date: Fri, 24 Jul 2026 18:36:11 +0700 Subject: [PATCH 2/3] refactor: update the code based on feedback --- AGENTS.md | 2 +- __tests__/api/agent-server-config.test.ts | 7 ++----- .../onboarding/onboarding-host.test.tsx | 10 ++++++++- __tests__/scripts/static-server.test.ts | 21 ++++++++++++++++++- docker/entrypoint.sh | 4 ++++ scripts/static-server.mjs | 17 ++++++++------- src/api/agent-server-config.ts | 5 ++++- .../features/onboarding/onboarding-host.tsx | 5 ++++- .../onboarding/use-onboarding-completion.ts | 10 +-------- src/root.tsx | 8 ++++++- 10 files changed, 61 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cc2205e2..29eed3814 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -585,7 +585,7 @@ When adding code that needs a new string, decide up front which rule it falls un - Changes tab / `FileDiffViewer` deleted-file note: the agent-server's `/api/git/diff` endpoint calls `path.exists()` first (see `openhands-sdk/openhands/sdk/git/git_diff.py` → `get_git_diff`), so requesting a diff for a `D` (deleted) file returns `GitPathError` → HTTP 400 and trips the global QueryCache error toast. `useUnifiedGitDiff` disables the query when `type === "D"` and `FileDiffViewer` renders a localized "file deleted" placeholder (`DIFF_VIEWER$FILE_DELETED`, `data-testid="file-deleted-message"`) instead of the view-mode toolbar / Monaco editor for that case. -- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is rendered by `` and gated by the `openhands-onboarded` localStorage flag. It tracks logical phases (`backend`, `agent`, `setup`, `hello`) rather than fixed numeric steps; the backend phase may be omitted for an already configured backend. Agent choices are derived from `ACP_PROVIDERS` plus OpenHands. The setup phase renders `SetupLlmStep` for OpenHands or `SetupAcpSecretsStep` for ACP providers. Keep the phase-based navigation so adding or removing the backend slide cannot move users to the wrong step. +- Onboarding modal: `src/components/features/onboarding/onboarding-modal.tsx` is rendered by `` and gated by the `openhands-onboarded` localStorage flag plus the deployment-level disable flag (`isOnboardingDisabled()` — see `VITE_DISABLE_ONBOARDING` above). It tracks logical phases (`backend`, `agent`, `setup`, `hello`) rather than fixed numeric steps; the backend phase may be omitted for an already configured backend. Agent choices are derived from `ACP_PROVIDERS` plus OpenHands. The setup phase renders `SetupLlmStep` for OpenHands or `SetupAcpSecretsStep` for ACP providers. Keep the phase-based navigation so adding or removing the backend slide cannot move users to the wrong step. - Files tab diff-view default logic: keyed off `useHasAttachedSource()` (`src/hooks/use-has-attached-source.ts`), which is true when the user explicitly attached _either_ a repo (`conversation.selected_repository`) _or_ a local workspace (`getStoredConversationMetadata(id).selected_workspace`, persisted by `createConversation` when `workingDirOverride` is supplied). The agent-server pre-initialises every conversation workspace as a git worktree for its own change tracking, so do NOT use a filesystem probe (`git status` / `useUnifiedGetGitChanges`) as the attachment signal — that was tried in earlier iterations and made every fresh no-attachment conversation incorrectly default to diff view. The companion `useHasGitCommits` probe (`src/hooks/query/use-has-git-commits.ts`) then suppresses diff view for attached-but-empty cases (unborn HEAD, non-git workspace). diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts index aa0debd4b..1b6437dca 100644 --- a/__tests__/api/agent-server-config.test.ts +++ b/__tests__/api/agent-server-config.test.ts @@ -31,6 +31,8 @@ afterEach(() => { .__AGENT_CANVAS_SESSION_API_KEY__; delete (window as unknown as Record) .__AGENT_CANVAS_LOCK_TO_CLOUD__; + delete (window as unknown as Record) + .__AGENT_CANVAS_DISABLE_ONBOARDING__; Object.defineProperty(window, "location", { configurable: true, value: ORIGINAL_LOCATION, @@ -249,11 +251,6 @@ describe("isAuthRequiredAndMissing", () => { }); describe("isOnboardingDisabled", () => { - afterEach(() => { - delete (window as unknown as Record) - .__AGENT_CANVAS_DISABLE_ONBOARDING__; - }); - it("returns false when neither env var nor window flag is set", () => { expect(isOnboardingDisabled()).toBe(false); }); diff --git a/__tests__/components/onboarding/onboarding-host.test.tsx b/__tests__/components/onboarding/onboarding-host.test.tsx index f36917be4..4f2ebf521 100644 --- a/__tests__/components/onboarding/onboarding-host.test.tsx +++ b/__tests__/components/onboarding/onboarding-host.test.tsx @@ -88,6 +88,8 @@ afterEach(() => { window.localStorage.clear(); vi.unstubAllEnvs(); __resetActiveStoreForTests(); + delete (window as unknown as Record) + .__AGENT_CANVAS_DISABLE_ONBOARDING__; }); describe("OnboardingHost", () => { @@ -245,7 +247,13 @@ describe("OnboardingHost", () => { }); it("does not mount the modal for a fresh Cloud user when onboarding is disabled for the deployment", () => { - vi.stubEnv("VITE_DISABLE_ONBOARDING", "true"); + // Runtime half of the flag — the injected window global is the path SaaS + // actually uses (the prebuilt bundle has no VITE_DISABLE_ONBOARDING baked + // in). The build-time half is covered through the root gate in + // __tests__/root.test.tsx. + ( + window as unknown as Record + ).__AGENT_CANVAS_DISABLE_ONBOARDING__ = true; seedCloudBackend(); vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ ...DEFAULT_SETTINGS, diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts index aca223cec..9a1970a1e 100644 --- a/__tests__/scripts/static-server.test.ts +++ b/__tests__/scripts/static-server.test.ts @@ -2,7 +2,7 @@ import type { Server } from "node:http"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { parseArgs, startStaticServer } from "../../scripts/static-server.mjs"; @@ -11,6 +11,8 @@ describe("static-server.mjs", () => { const tempDirs: string[] = []; afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( servers.splice(0).map( (server) => @@ -282,6 +284,23 @@ describe("static-server.mjs", () => { expect(body).not.toContain("__AGENT_CANVAS_DISABLE_ONBOARDING__"); }); + + it("honors the AGENT_CANVAS_DISABLE_ONBOARDING env fallback", async () => { + // The SaaS Helm chart execs static-server.mjs directly and can only + // set container env vars (generic `env:` passthrough), not CLI flags. + vi.stubEnv("AGENT_CANVAS_DISABLE_ONBOARDING", "true"); + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const origin = await startServer(buildDir); + const body = await (await fetch(`${origin}/`)).text(); + + expect(body).toContain("window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true"); + }); }); describe("session key injection", () => { diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3c74317e1..87749940e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -17,6 +17,10 @@ # AGENT_SERVER_PORT – Internal agent-server port (default: 18000) # AUTOMATION_PORT – Internal automation port (default: 18001) # AGENT_CANVAS_BASE_PATH – Static frontend mount path (default: /canvas) +# AGENT_CANVAS_DISABLE_ONBOARDING – "true" skips the first-run onboarding +# wizard (read from the environment by +# static-server.mjs; deployment-wide, so the optional +# public-mode server below inherits it too) # PUBLIC_MODE_PORT – If set, starts a second static server on this port # with --auth-required (no session key injected) # OH_SECRET_KEY – Secret key for settings encryption (auto-generated diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index 0d44c1e59..68a96766a 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -563,12 +563,19 @@ export function startStaticServer(config) { const route = createRouter(config.routes); const proxy = createProxyHandlers({ label: `static:${config.port}` }); const dirAbs = resolve(config.dir); + // Env fallback so deployments that can only set container env vars (e.g. + // the SaaS Helm chart execs this script directly and only has a generic + // `env:` passthrough) can enable the flag without changing the CLI + // invocation. Env can only turn the flag ON. + const disableOnboarding = + config.disableOnboarding || + process.env.AGENT_CANVAS_DISABLE_ONBOARDING === "true"; const injectionOpts = { sessionApiKey: config.sessionApiKey || null, authRequired: config.authRequired || false, runtimeServicesInfo: config.runtimeServicesInfo || null, lockToCloud: config.lockToCloud || null, - disableOnboarding: config.disableOnboarding || false, + disableOnboarding, basePath: normalizeBasePath(config.basePath), }; const basePath = injectionOpts.basePath; @@ -633,7 +640,7 @@ export function startStaticServer(config) { if (config.lockToCloud) { console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`); } - if (config.disableOnboarding) { + if (disableOnboarding) { console.log(" First-run onboarding: disabled"); } console.log(" * (default) -> static files + SPA fallback"); @@ -653,12 +660,6 @@ const isMainModule = if (isMainModule) { try { const config = parseArgs(); - // Env fallback so deployments that can only set container env vars - // (e.g. the Helm chart's generic `env:` passthrough) can enable the - // flag without changing the CLI invocation. Env can only turn it ON. - if (process.env.AGENT_CANVAS_DISABLE_ONBOARDING === "true") { - config.disableOnboarding = true; - } await startStaticServer(config); } catch (err) { console.error(err instanceof Error ? err.message : err); diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts index e6bae71f0..7c230493f 100644 --- a/src/api/agent-server-config.ts +++ b/src/api/agent-server-config.ts @@ -231,7 +231,10 @@ export function isAuthRequiredAndMissing(): boolean { * from build-time `VITE_DISABLE_ONBOARDING=true` or the runtime * `window.__AGENT_CANVAS_DISABLE_ONBOARDING__` global injected by * `scripts/static-server.mjs --disable-onboarding`. Cloud login is not - * onboarding and is never suppressed by this flag. + * onboarding: on a fresh browser in locked-to-Cloud mode the first-run flow + * (which owns the Cloud login) still shows, because the locked-mode gate in + * `root.tsx` ignores the completion flag until the locked Cloud backend is + * active. */ export function isOnboardingDisabled(): boolean { return ( diff --git a/src/components/features/onboarding/onboarding-host.tsx b/src/components/features/onboarding/onboarding-host.tsx index d40f7ce42..00af5cf6d 100644 --- a/src/components/features/onboarding/onboarding-host.tsx +++ b/src/components/features/onboarding/onboarding-host.tsx @@ -1,4 +1,5 @@ import { useLocation } from "react-router"; +import { isOnboardingDisabled } from "#/api/agent-server-config"; import { OnboardingModal } from "./onboarding-modal"; import { isOnboardingPreviewActive, @@ -27,7 +28,9 @@ export function OnboardingHost() { const { isCompleted, markCompleted } = useOnboardingCompletion(); if (!isPreview) { - if (isCompleted) return null; + // Deployment-level suppression (VITE_DISABLE_ONBOARDING or the injected + // window global) closes the gate without faking the completion marker. + if (isCompleted || isOnboardingDisabled()) return null; } const handleClose = () => { diff --git a/src/components/features/onboarding/use-onboarding-completion.ts b/src/components/features/onboarding/use-onboarding-completion.ts index ab9a15bee..3853e1869 100644 --- a/src/components/features/onboarding/use-onboarding-completion.ts +++ b/src/components/features/onboarding/use-onboarding-completion.ts @@ -1,7 +1,5 @@ import React from "react"; -import { isOnboardingDisabled } from "#/api/agent-server-config"; - /** * localStorage key persisting whether the welcome onboarding flow has * been completed (or skipped). Once present, the modal won't auto-show @@ -51,11 +49,5 @@ export function useOnboardingCompletion() { setIsCompleted(true); }, []); - // A deployment-level disable flag reports "completed" so both wizard - // gates (root.tsx, onboarding-host.tsx) stay closed; the - // ?previewOnboardingStep harness bypasses isCompleted and keeps working. - return { - isCompleted: isOnboardingDisabled() || isCompleted, - markCompleted, - } as const; + return { isCompleted, markCompleted } as const; } diff --git a/src/root.tsx b/src/root.tsx index 769ec6a4a..9b1335250 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -24,6 +24,7 @@ import { getLockedCloudAuthMode, getLockedCloudHost, isAuthRequiredAndMissing, + isOnboardingDisabled, isSameCloudHost, } from "#/api/agent-server-config"; import { @@ -259,8 +260,13 @@ export default function App() { isLockedToCloud && active.backend.kind === "cloud" && isSameCloudHost(active.backend.host, lockedCloudHost); - const { isCompleted: onboardingCompleted, markCompleted } = + const { isCompleted: onboardingMarkedCompleted, markCompleted } = useOnboardingCompletion(); + // Deployment-level suppression (VITE_DISABLE_ONBOARDING or the injected + // window global) reads as "completed" so the first-run gate below stays + // closed without faking the localStorage marker. + const onboardingCompleted = + onboardingMarkedCompleted || isOnboardingDisabled(); // In locked-to-Cloud mode the `openhands-onboarded` localStorage flag is // not trustworthy: it may have been set during a previous non-locked From 5af89222895ead2c16d1c0e915b78e5cc0aadba3 Mon Sep 17 00:00:00 2001 From: hieptl Date: Sat, 25 Jul 2026 12:25:51 +0700 Subject: [PATCH 3/3] refactor: update the code based on feedback --- __tests__/scripts/static-server.test.ts | 22 +++++++++++++++++++ scripts/static-server.mjs | 18 ++++++++++++--- .../features/onboarding/onboarding-host.tsx | 12 +++++----- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts index 9a1970a1e..f4e9eeda9 100644 --- a/__tests__/scripts/static-server.test.ts +++ b/__tests__/scripts/static-server.test.ts @@ -12,6 +12,7 @@ describe("static-server.mjs", () => { afterEach(async () => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); await Promise.all( servers.splice(0).map( @@ -301,6 +302,27 @@ describe("static-server.mjs", () => { expect(body).toContain("window.__AGENT_CANVAS_DISABLE_ONBOARDING__=true"); }); + + it("warns and stays off when the env var has an unrecognized value", async () => { + // A Helm typo like "True" or "1" must surface in the container logs + // instead of silently no-oping. + vi.stubEnv("AGENT_CANVAS_DISABLE_ONBOARDING", "True"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const origin = await startServer(buildDir); + const body = await (await fetch(`${origin}/`)).text(); + + expect(body).not.toContain("__AGENT_CANVAS_DISABLE_ONBOARDING__"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('AGENT_CANVAS_DISABLE_ONBOARDING="True"'), + ); + }); }); describe("session key injection", () => { diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index 68a96766a..7f754cc8d 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -566,10 +566,22 @@ export function startStaticServer(config) { // Env fallback so deployments that can only set container env vars (e.g. // the SaaS Helm chart execs this script directly and only has a generic // `env:` passthrough) can enable the flag without changing the CLI - // invocation. Env can only turn the flag ON. + // invocation. Env can only turn the flag ON, and only the exact string + // "true" does so — warn on anything unrecognized so a Helm typo (e.g. + // "True" or "1") surfaces in the container logs instead of silently + // no-oping. + const envDisableOnboarding = process.env.AGENT_CANVAS_DISABLE_ONBOARDING; + if ( + envDisableOnboarding && + envDisableOnboarding !== "true" && + envDisableOnboarding !== "false" + ) { + console.warn( + `AGENT_CANVAS_DISABLE_ONBOARDING="${envDisableOnboarding}" is ignored — only "true" enables it`, + ); + } const disableOnboarding = - config.disableOnboarding || - process.env.AGENT_CANVAS_DISABLE_ONBOARDING === "true"; + config.disableOnboarding || envDisableOnboarding === "true"; const injectionOpts = { sessionApiKey: config.sessionApiKey || null, authRequired: config.authRequired || false, diff --git a/src/components/features/onboarding/onboarding-host.tsx b/src/components/features/onboarding/onboarding-host.tsx index 00af5cf6d..62c82229b 100644 --- a/src/components/features/onboarding/onboarding-host.tsx +++ b/src/components/features/onboarding/onboarding-host.tsx @@ -9,13 +9,15 @@ import { useOnboardingCompletion } from "./use-onboarding-completion"; /** * Mounts the onboarding modal automatically the first time the user - * lands on a host route (i.e. when the localStorage onboarding flag - * isn't set yet). Closing or completing the flow marks it done so the - * modal won't re-appear on subsequent visits. + * lands on a host route — gated on the localStorage onboarding flag not + * being set yet and on deployment-level suppression + * (`isOnboardingDisabled()`) being off. Closing or completing the flow + * marks it done so the modal won't re-appear on subsequent visits. * * Backend readiness is intentionally not treated as onboarding completion: - * a fresh browser/origin should see onboarding once even when it connects - * to an existing backend that already has an LLM configured. + * absent deployment-level suppression, a fresh browser/origin should see + * onboarding once even when it connects to an existing backend that + * already has an LLM configured. * * With `?previewOnboardingStep=<0-3>` the modal opens on that slide for * design review without persisting completion (works on any route when