diff --git a/e2e/lib/vitest/packaged-app-shell.ts b/e2e/lib/vitest/packaged-app-shell.ts index 2c5e8d3563..77a90f6891 100644 --- a/e2e/lib/vitest/packaged-app-shell.ts +++ b/e2e/lib/vitest/packaged-app-shell.ts @@ -95,6 +95,21 @@ export function asPackagedAppShellSnapshot(value: unknown): PackagedAppShellSnap */ export type PackagedAppShellState = 'home' | 'onboarding-landing'; +/** + * Whether a health/readiness observation still belongs to the packaged + * renderer. Route ownership is the health boundary; the app-shell probe below + * decides whether a particular route rendered an allowed product surface. + */ +export function packagedAppRouteUrl(value: unknown): boolean { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'od:' && url.hostname === 'app'; + } catch { + return false; + } +} + /** * Which settled surface the renderer is showing, or `null` while it is showing * neither — a blank window, a crashed renderer, a boot still on the loader, or @@ -337,12 +352,11 @@ export type PackagedAppShellPolicyInput = { * * Derived from the daemon's own `onboardingCompleted`, never from the smoke * profile, so a run's setup and its accepted terminal state cannot disagree. - * The smoke seeds `onboardingCompleted: true` before start; if the daemon - * confirms it, the renderer must honour it and home is the only acceptable - * outcome — that is what keeps a broken completed-onboarding boot path - * detectable on the core release lane. Only a run whose daemon reports - * onboarding as *not* completed is a genuine first run, and only then is the - * cloud sign-in landing a legitimate place to stop. + * The smoke seeds `onboardingCompleted: true` before start and independently + * confirms that the daemon retains it. The auth-first entry shell may still + * route a signed-out core run to the cloud sign-in landing; that is an identity + * gate, not evidence that onboarding state was lost. A genuine first run may + * stop on the same surface after the daemon explicitly reports `false`. * * `coreProfile` still narrows it: the full profile goes on to drive the entry * rail, which `clickUpdaterRailExpression` refuses while onboarding is up, so @@ -351,18 +365,20 @@ export type PackagedAppShellPolicyInput = { export function packagedAppShellPolicy( input: PackagedAppShellPolicyInput, ): { readonly acceptOnboardingLanding: boolean } { - // A run that seeded completion and saw the daemon confirm it may never accept - // the landing, whatever a later reading says. Losing the seed across a - // relaunch is a regression, not a downgrade to "genuine first run" — see - // `assertSeededOnboardingRetained`, which is what turns that loss into a - // named failure rather than merely a stricter expectation here. - if (input.seededOnboardingCompleted !== false) return { acceptOnboardingLanding: false }; - // Only an explicit `false` — a daemon that positively said "not completed" — - // buys permission. Testing for truthiness instead would let any non-boolean - // that leaked past the type fall through to the permissive branch, which is - // the same shape of defect as coercing the reading in the first place. - if (input.daemonOnboardingCompleted !== false) return { acceptOnboardingLanding: false }; - return { acceptOnboardingLanding: input.coreProfile === true }; + if (input.coreProfile !== true) return { acceptOnboardingLanding: false }; + // A completed-user run earns auth-first permission only when both the seed + // and the daemon reading are explicit `true`. `assertSeededOnboardingRetained` + // turns the `true -> false` cold-launch regression into a named failure before + // this policy is applied. + if (input.seededOnboardingCompleted === true) { + return { acceptOnboardingLanding: input.daemonOnboardingCompleted === true }; + } + // A first-run landing likewise requires two explicit facts. Closed checks + // keep malformed values from falling through to the permissive branch. + return { + acceptOnboardingLanding: + input.seededOnboardingCompleted === false && input.daemonOnboardingCompleted === false, + }; } /** @@ -445,12 +461,11 @@ export function assertSeededOnboardingRetained(input: { /** * The two launch scenarios the packaged app legitimately has. * - * Both are real product behaviour, not a contradiction. - * `shouldRouteToFirstRunOnboarding` (apps/web/src/App.tsx) keys purely on - * `onboardingCompleted`, and `connectStepRuntimeReady` (EntryShell.tsx) lets - * onboarding complete through cloud sign-in *or* a local CLI *or* a verified - * BYOK key. So "completed setup, currently signed out -> home" and "never - * completed -> cloud sign-in landing" are both correct. + * Both are real product behaviour, not a contradiction. A first run reaches + * the cloud sign-in landing because setup has not completed; a completed but + * signed-out core run can reach the same landing because identity is now the + * entry gate. The daemon config reading distinguishes the two and the retained + * seed proves a protocol cold launch did not switch data roots. */ export type PackagedLaunchScenario = 'completed-user' | 'first-run'; diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts index e460425a10..4aff0d1b7c 100644 --- a/e2e/specs/win.spec.ts +++ b/e2e/specs/win.spec.ts @@ -12,6 +12,7 @@ import { describe, expect, test } from 'vitest'; import { packagedAppShellExpression, + packagedAppRouteUrl, PackagedOnboardingConfigError, packagedOnboardingCompletedFromProbe, packagedOnboardingConfigExpression, @@ -662,15 +663,30 @@ winDescribe('packaged windows runtime smoke', () => { const inspect = await measureSmokeStep(timings, 'wait healthy inspect eval', async () => waitForHealthyDesktop()); expect(inspect.status?.state).toBe('running'); if (inspect.desktopIpcUnavailable) expectWindowsFallbackWebUrl(inspect.status?.url); - else expectWindowsPackagedAppUrl(inspect.status?.url); + else expectWindowsPackagedRouteUrl(inspect.status?.url); const value = assertHealthEvalValue(inspect.eval?.value); if (inspect.desktopIpcUnavailable) expectWindowsDaemonUrl(value.href); - else expectWindowsPackagedAppUrl(value.href); + else expectWindowsPackagedRouteUrl(value.href); expect(value.status).toBe(200); expect(value.health.ok).toBe(true); if (releaseVersion != null && releaseVersion !== '') expect(value.health.version).toBe(releaseVersion); else expect(value.health.version).toEqual(expect.any(String)); + + // Establish the data-root postcondition before probing unrelated runtime + // capabilities. A healthy auth-first renderer may already be on + // od://app/onboarding, but it must still read the completed seed written + // into this tools-pack namespace. + if (!inspect.desktopIpcUnavailable) { + seededOnboardingCompleted = await measureSmokeStep(timings, 'verify seeded onboarding config', async () => + packagedOnboardingCompletedFromProbe(await readPackagedOnboardingConfig()), + ); + expect( + seededOnboardingCompleted, + 'daemon did not read the seeded onboardingCompleted config; check that the packaged data root still resolves to the tools-pack runtime namespace root', + ).toBe(true); + } + const ptyInspect = await measureSmokeStep(timings, 'packaged PTY capability', async () => runToolsPackJson('inspect', [ '--expr', @@ -689,23 +705,6 @@ winDescribe('packaged windows runtime smoke', () => { assertLauncherPointer(inspect.launcher.active, updateScenario.expectedCurrentVersion, 0, 'initial active'); assertLauncherPointer(inspect.launcher.lastSuccessful, updateScenario.expectedCurrentVersion, 0, 'initial lastSuccessful'); - // The seed's postcondition, asserted where it must hold: this process was - // started by `tools-pack win start`, which points the packaged runtime at - // the same data root `seedPackagedOnboardingComplete` wrote. If the daemon - // does not see it here, the completed-onboarding boot path is broken — - // the #4389-era failure where the seed landed in the AppData fallback and - // the daemon never read it. Fail with that named cause instead of letting - // it surface later as an unexplained onboarding screen. - if (!inspect.desktopIpcUnavailable) { - seededOnboardingCompleted = await measureSmokeStep(timings, 'verify seeded onboarding config', async () => - packagedOnboardingCompletedFromProbe(await readPackagedOnboardingConfig()), - ); - expect( - seededOnboardingCompleted, - 'daemon did not read the seeded onboardingCompleted config; check that the packaged data root still resolves to the tools-pack runtime namespace root', - ).toBe(true); - } - // Runtime registration must preserve the stable installed outer path; // pointing at a versioned payload would break the scheme after cleanup. await assertWindowsInviteProtocolRegistration(install.installDir); @@ -753,8 +752,9 @@ winDescribe('packaged windows runtime smoke', () => { // environment — so it is a different daemon, and only it can say what // config the surface being asserted on is actually running under. // Phase 2 — the completed user. The seed must have been confirmed before - // this point; anything else means the run never established the fact - // this phase depends on. + // this point; the core auth-first profile may legitimately stop at the + // cloud sign-in landing, while the full updater profile still needs + // Home. Either way, a cold launch that lost the seed fails first. if (seededOnboardingCompleted !== true) { throw new Error('reached the completed-user app-shell check without a confirmed seeded onboarding state'); } @@ -769,7 +769,7 @@ winDescribe('packaged windows runtime smoke', () => { ); onboardingCompleted = completedUser.onboardingCompleted; appShell = completedUser.appShell; - expect(appShell).toBe('home'); + if (!verifyCoreOnly) expect(appShell).toBe('home'); if (verifyUpgradePersistence) { const seedInspect = await measureSmokeStep(timings, 'seed pre-update persistence project', async () => @@ -2563,6 +2563,10 @@ function expectWindowsPackagedAppUrl(value: string | null | undefined): void { expect(value).toEqual(expect.stringMatching(/^od:\/\/app\/$/)); } +function expectWindowsPackagedRouteUrl(value: string | null | undefined): void { + expect(packagedAppRouteUrl(value), `${String(value)} should be an od://app/* packaged renderer URL`).toBe(true); +} + function expectWindowsFallbackWebUrl(value: string | null | undefined): void { expect(value).toEqual(expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/?$/)); } diff --git a/e2e/tests/packaged/app-shell.test.ts b/e2e/tests/packaged/app-shell.test.ts index 4c0d3fb476..a4926e6d72 100644 --- a/e2e/tests/packaged/app-shell.test.ts +++ b/e2e/tests/packaged/app-shell.test.ts @@ -18,6 +18,7 @@ import { type PackagedOnboardingConfigFetch, packagedAppShellFailureReason, packagedAppShellPolicy, + packagedAppRouteUrl, packagedAppShellSettled, packagedAppShellState, type PackagedAppShellProbeDocument, @@ -90,15 +91,15 @@ function probe(document: PackagedAppShellProbeDocument): PackagedAppShellSnapsho return snapshot; } -// The surface a signed-in or onboarding-seeded packaged app comes up on. +// The surface a signed-in packaged app comes up on. const HOME_SHELL: readonly FixtureNode[] = [ { classes: ['entry-shell'] }, { attributes: { 'data-testid': 'entry-nav-home' }, classes: ['entry-nav__item'] }, { attributes: { 'data-testid': 'entry-nav-updater' }, classes: ['entry-nav__item'] }, ]; -// The surface a fresh packaged install comes up on since the #4513 cloud -// sign-in redesign: EntryShell's onboarding shell wrapping OnboardingView's +// The auth-first surface a fresh install or a completed but signed-out core +// run comes up on: EntryShell's onboarding shell wrapping OnboardingView's // identity gate. Runtime selection intentionally follows Cloud sign-in. const CLOUD_SIGN_IN_LANDING: readonly FixtureNode[] = [ { classes: ['entry-shell', 'entry-shell--no-header', 'entry-shell--onboarding'] }, @@ -108,6 +109,12 @@ const CLOUD_SIGN_IN_LANDING: readonly FixtureNode[] = [ ]; describe('packaged app-shell probe', () => { + it('accepts auth-first routes as packaged renderer URLs', () => { + expect(packagedAppRouteUrl('od://app/')).toBe(true); + expect(packagedAppRouteUrl('od://app/onboarding')).toBe(true); + expect(packagedAppRouteUrl('http://127.0.0.1:3000/')).toBe(false); + }); + it('ships a self-contained expression that reads the globals the renderer has', () => { expect(packagedAppShellExpression).toContain('(document, HTMLElement)'); expect(packagedAppShellExpression).toContain('[data-testid="entry-nav-home"]'); @@ -230,17 +237,29 @@ describe('packaged app-shell terminal state', () => { }); // The postcondition PerishCode's review on #6481 asked to keep: a run that -// seeds onboarding as completed must still notice when the app ignores it. -// The setup's claim and the accepted terminal state have to come from the same -// fact, so the policy reads the daemon's own `onboardingCompleted` (served from -// `readAppConfig(RUNTIME_DATA_DIR)`) rather than the smoke profile. +// seeds onboarding as completed must still notice when a cold launch loses it. +// Auth-first means a retained completed user may legitimately see the same +// cloud sign-in landing as a first run, so the seed check and surface policy +// must stay separate. describe('packaged app-shell policy', () => { - it('requires home once the daemon confirms onboarding is completed', () => { + it('accepts the auth-first landing when a seeded core run retained onboarding completion', () => { + const landing = probe(renderFixture(CLOUD_SIGN_IN_LANDING)); + const policy = packagedAppShellPolicy({ + coreProfile: true, + daemonOnboardingCompleted: true, + seededOnboardingCompleted: true, + }); + + expect(policy).toEqual({ acceptOnboardingLanding: true }); + expect(packagedAppShellSettled(landing, policy)).toBe(true); + }); + + it('requires home when the scenario does not establish auth-first permission', () => { expect( packagedAppShellPolicy({ coreProfile: true, daemonOnboardingCompleted: true, seededOnboardingCompleted: false }), ).toEqual({ acceptOnboardingLanding: false }); expect( - packagedAppShellPolicy({ coreProfile: false, daemonOnboardingCompleted: true, seededOnboardingCompleted: false }), + packagedAppShellPolicy({ coreProfile: false, daemonOnboardingCompleted: true, seededOnboardingCompleted: true }), ).toEqual({ acceptOnboardingLanding: false }); }); @@ -250,7 +269,7 @@ describe('packaged app-shell policy', () => { ).toEqual({ acceptOnboardingLanding: true }); }); - it('keeps a seeded run failing when the app ignores completed onboarding', () => { + it('does not infer completed-user auth permission without a seed', () => { const landing = probe(renderFixture(CLOUD_SIGN_IN_LANDING)); const policy = packagedAppShellPolicy({ coreProfile: true, daemonOnboardingCompleted: true, seededOnboardingCompleted: false }); @@ -566,20 +585,20 @@ describe('packaged launch scenarios', () => { expect(result).toEqual({ appShell: 'home', onboardingCompleted: true }); }); - it('fails a completed user that lands on onboarding instead of home', async () => { + it('settles a retained completed user on the core auth-first landing', async () => { const clock = virtualClock(); const landing = probe(renderFixture(CLOUD_SIGN_IN_LANDING)); - await expect( - runPackagedAppShellPhase({ - coreProfile: true, - now: clock.now, - observe: async () => landing, - readOnboardingConfig: async () => ({ kind: 'reading', ok: true, onboardingCompleted: true, status: 200 }), - scenario: 'completed-user', - sleep: clock.sleep, - }), - ).rejects.toThrow(/needs home/); + const result = await runPackagedAppShellPhase({ + coreProfile: true, + now: clock.now, + observe: async () => landing, + readOnboardingConfig: async () => ({ kind: 'reading', ok: true, onboardingCompleted: true, status: 200 }), + scenario: 'completed-user', + sleep: clock.sleep, + }); + + expect(result).toEqual({ appShell: 'onboarding-landing', onboardingCompleted: true }); }); it('fails a completed user whose seeded state was lost across the relaunch', async () => { diff --git a/tools/pack/src/win/lifecycle.ts b/tools/pack/src/win/lifecycle.ts index 994dddc4ac..b6ce708f88 100644 --- a/tools/pack/src/win/lifecycle.ts +++ b/tools/pack/src/win/lifecycle.ts @@ -187,6 +187,13 @@ export async function installPackedWinApp(config: ToolPackConfig): Promise { + await pinInstalledPackagedConfigNamespace(config, paths.installedExePath); + }); const registryEntries = await measureLifecycleStep(lifecycleTimings, "query registry", async () => queryWinRegistryEntries(paths, config)); const installPayload = await measureLifecycleStep(lifecycleTimings, "collect payload report", async () => collectInstallPayloadReport(paths)); await measureLifecycleStep(lifecycleTimings, "write install marker", async () => writeJsonMarker(paths.installMarkerPath, { @@ -214,16 +221,42 @@ export async function installPackedWinApp(config: ToolPackConfig): Promise { +/** + * Pin the tools-pack runtime namespace into the installed app's packaged config + * and write the launch override used by `tools-pack win start`. + * + * The installed config is the only source available to a bare executable + * launched through the Windows protocol registry. Keeping both copies + * identical prevents that cold launch from resolving a different daemon data + * root than the process started by tools-pack. + */ +async function pinInstalledPackagedConfigNamespace( + config: ToolPackConfig, + executablePath: string, +): Promise<{ installedConfigPath: string; launchConfigPath: string }> { const installedConfigPath = join(dirname(executablePath), "resources", "open-design-config.json"); + if (!(await pathExists(installedConfigPath))) { + throw new Error(`installed packaged config missing at ${installedConfigPath}`); + } const raw = JSON.parse(await readFile(installedConfigPath, "utf8")) as Record; + if (typeof raw !== "object" || raw == null || Array.isArray(raw)) { + throw new Error(`installed packaged config must be a JSON object: ${installedConfigPath}`); + } + const pinned = { + ...raw, + namespace: config.namespace, + namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot, + }; + const body = `${JSON.stringify(pinned, null, 2)}\n`; + await writeFile(installedConfigPath, body, "utf8"); const launchConfigPath = join(config.roots.runtime.namespaceRoot, "runtime", "launch-open-design-config.json"); await mkdir(dirname(launchConfigPath), { recursive: true }); - await writeFile( - launchConfigPath, - `${JSON.stringify({ ...raw, namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }, null, 2)}\n`, - "utf8", - ); + await writeFile(launchConfigPath, body, "utf8"); + return { installedConfigPath, launchConfigPath }; +} + +async function writeInstalledLaunchPackagedConfig(config: ToolPackConfig, executablePath: string): Promise { + const { launchConfigPath } = await pinInstalledPackagedConfigNamespace(config, executablePath); return launchConfigPath; } diff --git a/tools/pack/tests/win-lifecycle.test.ts b/tools/pack/tests/win-lifecycle.test.ts index 024c5b9ae8..829ca3df35 100644 --- a/tools/pack/tests/win-lifecycle.test.ts +++ b/tools/pack/tests/win-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -110,6 +110,40 @@ async function writeFakeUnpackedExe(config: ToolPackConfig): Promise { } describe("installPackedWinApp", () => { + it("pins the installed portable config to the tools-pack namespace for bare protocol launches", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); + const config = { ...createConfig(root), portable: true }; + const paths = resolveWinPaths(config); + const installedConfigPath = join(paths.installDir, "resources", "open-design-config.json"); + + try { + await mkdir(dirname(paths.setupPath), { recursive: true }); + await writeFile(paths.setupPath, "", "utf8"); + invokeNsis.mockReset(); + invokeNsis.mockImplementation(async () => { + await mkdir(dirname(installedConfigPath), { recursive: true }); + await writeFile(paths.installedExePath, "", "utf8"); + await writeFile( + installedConfigPath, + `${JSON.stringify({ channel: "prerelease", namespace: "baked-default" }, null, 2)}\n`, + "utf8", + ); + }); + + const result = await installPackedWinApp(config); + const installedConfig = JSON.parse(await readFile(installedConfigPath, "utf8")) as Record; + + expect(installedConfig).toMatchObject({ + channel: "prerelease", + namespace: config.namespace, + namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot, + }); + expect(result.lifecycleTimings.map(({ step }) => step)).toContain("pin installed packaged namespace"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it("creates the exact fresh install directory before invoking transactional NSIS", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-win-lifecycle-")); const config = createConfig(root); @@ -121,7 +155,10 @@ describe("installPackedWinApp", () => { invokeNsis.mockReset(); invokeNsis.mockImplementation(async () => { await expect(access(paths.installDir)).resolves.toBeUndefined(); + const installedConfigPath = join(paths.installDir, "resources", "open-design-config.json"); + await mkdir(dirname(installedConfigPath), { recursive: true }); await writeFile(paths.installedExePath, "", "utf8"); + await writeFile(installedConfigPath, "{}\n", "utf8"); }); const result = await installPackedWinApp(config);