| @@ -489,15 +489,6 @@ const closePackaged = async (app: PackagedApp | undefined): Promise<void> => { · 488 unmodified lines |
| 489 | 489 | await stopProcess(app?.child); |
| 490 | 490 | }; |
| 491 | 491 | |
| 492 | | −const waitUntil = async (predicate: () => boolean, timeoutMs: number): Promise<boolean> => { |
| 493 | | − const deadline = Date.now() + timeoutMs; |
| 494 | | − for (;;) { |
| 495 | | − if (predicate()) return true; |
| 496 | | − if (Date.now() >= deadline) return false; |
| 497 | | − await new Promise((resolve) => setTimeout(resolve, 100)); |
| 498 | | − } |
| 499 | | −}; |
| 500 | | − |
| 501 | 492 | const waitForServerConnectionLabel = async ( |
| 502 | 493 | page: CdpPage, |
| 503 | 494 | expectedText: string, |
| @@ -661,9 +652,12 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 157 unmodified lines |
| 661 | 652 | it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {}); |
| 662 | 653 | } else { |
| 663 | 654 | scenario( |
| 664 | | − "Desktop packaged supervised attach · stale manifest probe does not send the saved bearer", |
| 655 | + "Desktop packaged supervised attach · a slow live daemon does not look crashed", |
| 665 | 656 | { timeout: 240_000 }, |
| 666 | | − Effect.promise(() => runStaleManifestProbe()), |
| 657 | + Effect.gen(function* () { |
| 658 | + const runDir = yield* RunDir; |
| 659 | + yield* Effect.promise(() => runSlowLiveDaemonProbe(runDir)); |
| 660 | + }), |
| 667 | 661 | ); |
| 668 | 662 | |
| 669 | 663 | scenario( |
| @@ -685,81 +679,127 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 15 unmodified lines |
| 685 | 679 | ); |
| 686 | 680 | } |
| 687 | 681 | |
| 688 | | −const runStaleManifestProbe = async () => { |
| 689 | | − const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); |
| 682 | +const writeCliDaemonManifest = (input: { |
| 683 | + readonly controlDir: string; |
| 684 | + readonly dataDir: string; |
| 685 | + readonly origin: string; |
| 686 | + readonly displayName: string; |
| 687 | + readonly token: string; |
| 688 | +}): void => { |
| 689 | + mkdirSync(input.controlDir, { recursive: true }); |
| 690 | + writeFileSync( |
| 691 | + join(input.controlDir, "server.json"), |
| 692 | + serializeExecutorLocalServerManifest({ |
| 693 | + version: 1, |
| 694 | + kind: "cli-daemon", |
| 695 | + pid: process.pid, |
| 696 | + startedAt: new Date().toISOString(), |
| 697 | + dataDir: input.dataDir, |
| 698 | + scopeDir: input.dataDir, |
| 699 | + connection: normalizeExecutorServerConnection({ |
| 700 | + origin: input.origin, |
| 701 | + displayName: input.displayName, |
| 702 | + auth: { kind: "bearer", token: input.token }, |
| 703 | + }), |
| 704 | + owner: { client: "cli", version: null, executablePath: null }, |
| 705 | + }), |
| 706 | + { mode: 0o600 }, |
| 707 | + ); |
| 708 | +}; |
| 709 | + |
| 710 | +const runSlowLiveDaemonProbe = async (runDir: string) => { |
| 711 | + const home = mkdtempSync(join(tmpdir(), "executor-pkg-slow-live-daemon-")); |
| 690 | 712 | const dataDir = join(home, ".executor"); |
| 691 | 713 | const controlDir = join(dataDir, "server-control"); |
| 692 | 714 | const manifestPath = join(controlDir, "server.json"); |
| 693 | | − const token = "stale-manifest-leaked-token"; |
| 715 | + const token = "slow-live-daemon-token"; |
| 694 | 716 | const launchdSnapshot = captureLaunchdService(); |
| 695 | 717 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; |
| 696 | | − let resolveFirst!: () => void; |
| 697 | | − const firstRequest = new Promise<void>((resolve) => { |
| 698 | | − resolveFirst = resolve; |
| 718 | + let healthMode: "ok" | "slow" = "ok"; |
| 719 | + let slowHealthResponses = 0; |
| 720 | + let resolveThirdSlowHealthResponse!: () => void; |
| 721 | + const thirdSlowHealthResponse = new Promise<void>((resolve) => { |
| 722 | + resolveThirdSlowHealthResponse = resolve; |
| 699 | 723 | }); |
| 700 | 724 | const server = createServer((req: IncomingMessage, res) => { |
| 725 | + const url = req.url ?? "/"; |
| 701 | 726 | requests.push({ |
| 702 | | − url: req.url ?? "/", |
| 727 | + url, |
| 703 | 728 | authorization: req.headers.authorization ?? null, |
| 704 | 729 | }); |
| 705 | | − resolveFirst(); |
| 730 | + if (url.startsWith("/api/health")) { |
| 731 | + if (healthMode === "slow") { |
| 732 | + setTimeout(() => { |
| 733 | + slowHealthResponses += 1; |
| 734 | + if (slowHealthResponses >= 3) resolveThirdSlowHealthResponse(); |
| 735 | + if (res.destroyed || res.writableEnded) return; |
| 736 | + res.writeHead(200, { "content-type": "text/plain" }); |
| 737 | + res.end("ok"); |
| 738 | + }, 5_000); |
| 739 | + return; |
| 740 | + } |
| 741 | + res.writeHead(200, { "content-type": "text/plain" }); |
| 742 | + res.end("ok"); |
| 743 | + return; |
| 744 | + } |
| 706 | 745 | res.writeHead(200, { "content-type": "text/html" }); |
| 707 | | − res.end("<!doctype html><title>stale daemon</title><body>stale daemon</body>"); |
| 746 | + res.end("<!doctype html><title>Executor</title><body><main>Fake Executor UI</main></body>"); |
| 708 | 747 | }); |
| 709 | 748 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 710 | 749 | const port = (server.address() as net.AddressInfo).port; |
| 711 | | − let appProcess: ChildProcess | undefined; |
| 712 | | − let appOutput = ""; |
| 750 | + let app: PackagedApp | undefined; |
| 713 | 751 | |
| 714 | 752 | try { |
| 715 | | − mkdirSync(controlDir, { recursive: true }); |
| 716 | | − writeFileSync( |
| 717 | | − join(controlDir, "server.json"), |
| 718 | | − serializeExecutorLocalServerManifest({ |
| 719 | | − version: 1, |
| 720 | | − kind: "cli-daemon", |
| 721 | | − pid: process.pid, |
| 722 | | − startedAt: new Date().toISOString(), |
| 723 | | − dataDir, |
| 724 | | − scopeDir: dataDir, |
| 725 | | − connection: normalizeExecutorServerConnection({ |
| 726 | | − origin: `http://127.0.0.1:${port}`, |
| 727 | | − displayName: "Stale daemon", |
| 728 | | − auth: { kind: "bearer", token }, |
| 729 | | − }), |
| 730 | | − owner: { client: "cli", version: null, executablePath: null }, |
| 731 | | − }), |
| 732 | | − { mode: 0o600 }, |
| 733 | | − ); |
| 734 | | − |
| 735 | | − appProcess = spawn(requireBundle().app, [], { |
| 736 | | − env: packagedAppEnv(home), |
| 737 | | − stdio: ["ignore", "pipe", "pipe"], |
| 753 | + writeCliDaemonManifest({ |
| 754 | + controlDir, |
| 755 | + dataDir, |
| 756 | + origin: `http://127.0.0.1:${port}`, |
| 757 | + displayName: "Slow live daemon", |
| 758 | + token, |
| 738 | 759 | }); |
| 739 | | − const collectOutput = (chunk: Buffer) => { |
| 740 | | − appOutput = (appOutput + chunk.toString()).slice(-8_192); |
| 741 | | − }; |
| 742 | | − appProcess.stdout?.on("data", collectOutput); |
| 743 | | − appProcess.stderr?.on("data", collectOutput); |
| 744 | 760 | |
| 745 | | − const probed = await Promise.race([ |
| 746 | | − firstRequest.then(() => true), |
| 747 | | − new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)), |
| 748 | | − ]); |
| 749 | | − expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true); |
| 761 | + app = await launchPackaged(home); |
| 762 | + const page = app.cdp; |
| 763 | + await page.waitForText("Fake Executor UI", 120_000); |
| 764 | + await page.screenshot(join(runDir, "01-attached-to-fake-daemon.png")); |
| 750 | 765 | |
| 766 | + const firstHealthProbe = requests.find((request) => request.url.startsWith("/api/health")); |
| 751 | 767 | expect( |
| 752 | | − requests[0]?.authorization ?? null, |
| 753 | | − "the stale-manifest reachability probe must not disclose the saved bearer", |
| 768 | + firstHealthProbe?.authorization ?? null, |
| 769 | + "the supervised-daemon health probe must not disclose the saved bearer", |
| 754 | 770 | ).toBeNull(); |
| 755 | 771 | |
| 756 | | − const manifestRemoved = await waitUntil(() => !existsSync(manifestPath), 15_000); |
| 772 | + healthMode = "slow"; |
| 773 | + const sawSlowMonitorProbe = await Promise.race([ |
| 774 | + thirdSlowHealthResponse.then(() => true), |
| 775 | + new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)), |
| 776 | + ]); |
| 777 | + expect( |
| 778 | + sawSlowMonitorProbe, |
| 779 | + "the packaged app should continue probing the supervised daemon after initial attach", |
| 780 | + ).toBe(true); |
| 781 | + |
| 782 | + expect( |
| 783 | + requests |
| 784 | + .filter((request) => request.url.startsWith("/api/health")) |
| 785 | + .map((request) => request.authorization), |
| 786 | + "supervised-daemon health probes must never carry the saved bearer", |
| 787 | + ).not.toContain(`Bearer ${token}`); |
| 757 | 788 | expect( |
| 758 | | − manifestRemoved, |
| 759 | | − "a live pid with a failed health probe must be removed before desktop falls back", |
| 789 | + existsSync(manifestPath), |
| 790 | + "a live daemon's manifest must survive transient health probe failures", |
| 760 | 791 | ).toBe(true); |
| 792 | + expect( |
| 793 | + await page.textPresent("The local Executor server stopped unexpectedly"), |
| 794 | + "one slow monitor probe should not show the crash screen", |
| 795 | + ).toBe(false); |
| 796 | + expect( |
| 797 | + await page.textPresent("Fake Executor UI"), |
| 798 | + "the original renderer should stay loaded", |
| 799 | + ).toBe(true); |
| 800 | + await page.screenshot(join(runDir, "02-still-rendering-after-slow-health.png")); |
| 761 | 801 | } finally { |
| 762 | | − await stopProcess(appProcess); |
| 802 | + await closePackaged(app); |
| 763 | 803 | await restoreLaunchdService(launchdSnapshot); |
| 764 | 804 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 765 | 805 | rmSync(home, { recursive: true, force: true }); |