From 0c39f1927218a362d47161410bd67af6ecfc62b3 Mon Sep 17 00:00:00 2001 From: Cody Date: Thu, 2 Jul 2026 15:44:17 +0000 Subject: [PATCH] Fix custom image refresh terminal flow Auto-open the browser terminal for SSH custom image setup sessions and keep Refresh on the returned active session so stale overview refetches cannot fall back to the old active-template panel. Demote the raw SSH command to an explicit fallback and cover the Refresh path in the UI test. Update the lockfile for the existing ssh2 server dependency so frozen installs pass. Co-Authored-By: Paperclip --- pnpm-lock.yaml | 57 +++++++++++++++++++++++ ui/src/pages/CompanyEnvironments.test.tsx | 15 +++--- ui/src/pages/CompanyEnvironments.tsx | 26 +++++++++-- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9fe18faac0..923c9380ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -785,6 +785,9 @@ importers: sharp: specifier: ^0.35.2 version: 0.35.2 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 ws: specifier: ^8.19.0 version: 8.19.0 @@ -4990,6 +4993,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5098,6 +5104,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-auth@1.6.20: resolution: {integrity: sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA==} peerDependencies: @@ -5210,6 +5219,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -5394,6 +5407,10 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -6833,6 +6850,9 @@ packages: resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} engines: {node: '>= 10.16.0'} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -7559,6 +7579,10 @@ packages: sqlite3@5.1.7: resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} @@ -7780,6 +7804,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -12315,6 +12342,10 @@ snapshots: asap@2.0.6: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} assistant-cloud@0.1.31: @@ -12390,6 +12421,10 @@ snapshots: baseline-browser-mapping@2.9.19: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + better-auth@1.6.20(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(kysely@0.29.2)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@22.19.21)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@6.4.1(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))): dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) @@ -12500,6 +12535,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -12685,6 +12723,12 @@ snapshots: dependencies: layout-base: 2.0.1 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + crelt@1.0.6: {} cross-env@10.1.0: @@ -14499,6 +14543,9 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + nan@2.28.0: + optional: true + nanoid@3.3.11: {} nanoid@3.3.15: {} @@ -15494,6 +15541,14 @@ snapshots: - supports-color optional: true + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + ssri@8.0.1: dependencies: minipass: 3.3.6 @@ -15753,6 +15808,8 @@ snapshots: safe-buffer: 5.2.1 optional: true + tweetnacl@0.14.5: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index ed8034068d8..9950fe8b578 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -629,7 +629,7 @@ describe("CompanyEnvironments — test provider button", () => { expect(getOpenDialog()?.textContent).not.toContain(command); }); - it("opens an embedded browser terminal while preserving the SSH command fallback", async () => { + it("opens an embedded browser terminal automatically while preserving the SSH command fallback", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const command = "ssh sandbox@setup.example.invalid -p 2222"; @@ -661,11 +661,8 @@ describe("CompanyEnvironments — test provider button", () => { await act(async () => click(editButtons(container)[0])); await waitForAssertion(() => { expect(getOpenDialog()?.textContent).toContain(command); - expect(findButton(getOpenDialog()!, "Open terminal")).toBeTruthy(); - }); - - await act(async () => click(findButton(getOpenDialog()!, "Open terminal"))); - await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Browser terminal"); + expect(getOpenDialog()?.textContent).toContain("SSH command fallback"); expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {}); expect(FakeWebSocket.instances).toHaveLength(1); }); @@ -885,5 +882,11 @@ describe("CompanyEnvironments — test provider button", () => { "company-1", { templateId: "template-active" }, ); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Browser terminal"); + expect(getOpenDialog()?.textContent).toContain("SSH command fallback"); + expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {}); + expect(FakeWebSocket.instances).toHaveLength(1); + }); }); }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index ac70d207af5..d1aab707308 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -313,8 +313,10 @@ function customImageTerminalStatusCopy(state: CustomImageTerminalConnectionState } function EnvironmentCustomImageBrowserTerminal({ + autoConnect = false, sessionId, }: { + autoConnect?: boolean; sessionId: string; }) { const [connectionState, setConnectionState] = useState("idle"); @@ -322,6 +324,7 @@ function EnvironmentCustomImageBrowserTerminal({ const [errorMessage, setErrorMessage] = useState(null); const terminalRef = useRef(null); const socketRef = useRef(null); + const autoConnectAttemptedSessionRef = useRef(null); const closeSocket = useCallback((reason = "operator_closed") => { const socket = socketRef.current; @@ -335,6 +338,7 @@ function EnvironmentCustomImageBrowserTerminal({ useEffect(() => { closeSocket("session_changed"); + autoConnectAttemptedSessionRef.current = null; setConnectionState("idle"); setOutput(""); setErrorMessage(null); @@ -412,6 +416,16 @@ function EnvironmentCustomImageBrowserTerminal({ } }, [closeSocket, sessionId]); + useEffect(() => { + if (!autoConnect || connectionState !== "idle") return; + if (autoConnectAttemptedSessionRef.current === sessionId) return; + const timeoutId = window.setTimeout(() => { + autoConnectAttemptedSessionRef.current = sessionId; + void connectTerminal(); + }, 0); + return () => window.clearTimeout(timeoutId); + }, [autoConnect, connectTerminal, connectionState, sessionId]); + const sendTerminalInput = useCallback((data: string) => { const socket = socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) return; @@ -581,7 +595,6 @@ function EnvironmentImageTemplatePanel({ latestSession: result.session, })); setSessionResult(result); - invalidateOverview(); pushToast({ title: "Setup session started", body: "Connect details are available while the session is active.", @@ -782,14 +795,17 @@ function EnvironmentImageTemplatePanel({ {session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? ( - + ) : null} {session.status === "waiting_for_user" && connectionCommand ? ( -
- +
+ + SSH command fallback + + {connectionCommand} -
+ ) : null} {session.status === "waiting_for_user" && connectionFallbackMessage ? (