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 ? (