| @@ -489,15 +489,6 @@ const closePackaged = async (app: PackagedApp | undefined): Promise<void> => { · 488 unmodified lines |
| 489 | await stopProcess(app?.child); | 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 | const waitForServerConnectionLabel = async ( | 492 | const waitForServerConnectionLabel = async ( |
| 502 | page: CdpPage, | 493 | page: CdpPage, |
| 503 | expectedText: string, | 494 | expectedText: string, |
| @@ -661,9 +652,12 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 157 unmodified lines |
| 661 | it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {}); | 652 | it.skip("Desktop packaged supervised attach security (needs a GUI display and no already-running Executor.app)", () => {}); |
| 662 | } else { | 653 | } else { |
| 663 | scenario( | 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 | { timeout: 240_000 }, | 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 | scenario( | 663 | scenario( |
| @@ -685,81 +679,127 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 15 unmodified lines |
| 685 | ); | 679 | ); |
| 686 | } | 680 | } |
| 687 | | 681 | |
| 688 | const runStaleManifestProbe = async () => { | 682 | const writeCliDaemonManifest = (input: { |
| 689 | const home = mkdtempSync(join(tmpdir(), "executor-pkg-stale-probe-")); | 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 | const dataDir = join(home, ".executor"); | 712 | const dataDir = join(home, ".executor"); |
| 691 | const controlDir = join(dataDir, "server-control"); | 713 | const controlDir = join(dataDir, "server-control"); |
| 692 | const manifestPath = join(controlDir, "server.json"); | 714 | const manifestPath = join(controlDir, "server.json"); |
| 693 | const token = "stale-manifest-leaked-token"; | 715 | const token = "slow-live-daemon-token"; |
| 694 | const launchdSnapshot = captureLaunchdService(); | 716 | const launchdSnapshot = captureLaunchdService(); |
| 695 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; | 717 | const requests: Array<{ readonly url: string; readonly authorization: string | null }> = []; |
| 696 | let resolveFirst!: () => void; | 718 | let healthMode: "ok" | "slow" = "ok"; |
| 697 | const firstRequest = new Promise<void>((resolve) => { | 719 | let slowHealthResponses = 0; |
| 698 | resolveFirst = resolve; | 720 | let resolveThirdSlowHealthResponse!: () => void; |
| | 721 | const thirdSlowHealthResponse = new Promise<void>((resolve) => { |
| | 722 | resolveThirdSlowHealthResponse = resolve; |
| 699 | }); | 723 | }); |
| 700 | const server = createServer((req: IncomingMessage, res) => { | 724 | const server = createServer((req: IncomingMessage, res) => { |
| | 725 | const url = req.url ?? "/"; |
| 701 | requests.push({ | 726 | requests.push({ |
| 702 | url: req.url ?? "/", | 727 | url, |
| 703 | authorization: req.headers.authorization ?? null, | 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 | res.writeHead(200, { "content-type": "text/html" }); | 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 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | 748 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 710 | const port = (server.address() as net.AddressInfo).port; | 749 | const port = (server.address() as net.AddressInfo).port; |
| 711 | let appProcess: ChildProcess | undefined; | 750 | let app: PackagedApp | undefined; |
| 712 | let appOutput = ""; | | |
| 713 | | 751 | |
| 714 | try { | 752 | try { |
| 715 | mkdirSync(controlDir, { recursive: true }); | 753 | writeCliDaemonManifest({ |
| 716 | writeFileSync( | 754 | controlDir, |
| 717 | join(controlDir, "server.json"), | 755 | dataDir, |
| 718 | serializeExecutorLocalServerManifest({ | 756 | origin: `http://127.0.0.1:${port}`, |
| 719 | version: 1, | 757 | displayName: "Slow live daemon", |
| 720 | kind: "cli-daemon", | 758 | token, |
| 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"], | | |
| 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([ | 761 | app = await launchPackaged(home); |
| 746 | firstRequest.then(() => true), | 762 | const page = app.cdp; |
| 747 | new Promise<false>((resolve) => setTimeout(() => resolve(false), 60_000)), | 763 | await page.waitForText("Fake Executor UI", 120_000); |
| 748 | ]); | 764 | await page.screenshot(join(runDir, "01-attached-to-fake-daemon.png")); |
| 749 | expect(probed, `packaged app probed the stale manifest endpoint\n${appOutput}`).toBe(true); | | |
| 750 | | 765 | |
| | 766 | const firstHealthProbe = requests.find((request) => request.url.startsWith("/api/health")); |
| 751 | expect( | 767 | expect( |
| 752 | requests[0]?.authorization ?? null, | 768 | firstHealthProbe?.authorization ?? null, |
| 753 | "the stale-manifest reachability probe must not disclose the saved bearer", | 769 | "the supervised-daemon health probe must not disclose the saved bearer", |
| 754 | ).toBeNull(); | 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 | expect( | 788 | expect( |
| 758 | manifestRemoved, | 789 | existsSync(manifestPath), |
| 759 | "a live pid with a failed health probe must be removed before desktop falls back", | 790 | "a live daemon's manifest must survive transient health probe failures", |
| 760 | ).toBe(true); | 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 | } finally { | 801 | } finally { |
| 762 | await stopProcess(appProcess); | 802 | await closePackaged(app); |
| 763 | await restoreLaunchdService(launchdSnapshot); | 803 | await restoreLaunchdService(launchdSnapshot); |
| 764 | await new Promise<void>((resolve) => server.close(() => resolve())); | 804 | await new Promise<void>((resolve) => server.close(() => resolve())); |
| 765 | rmSync(home, { recursive: true, force: true }); | 805 | rmSync(home, { recursive: true, force: true }); |