From bcac517f3b9255f7daaec7ca46f703906de2284b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 3 Jul 2026 16:44:21 -0700 Subject: [PATCH] Add browser SSH terminal for custom image setup (#8911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Environment sandboxes already support custom image creation and refresh through a temporary SSH setup session. > - The existing workflow makes operators copy an SSH command into an external terminal before they can install packages or make image changes. > - That extra context switch is slower, easier to get wrong, and less integrated with the setup session Paperclip already tracks. > - This pull request adds an embedded browser SSH terminal for custom image setup, so operators can start working in the target sandbox directly from the environment configuration flow. > - The implementation uses short-lived websocket attachment tokens, session-lifetime SSH host-key pinning, and server-managed terminal cleanup so the feature fits the existing setup-session boundary. > - The benefit is a smoother custom image creation and refresh experience without asking users to leave Paperclip for routine sandbox setup work. ## Linked Issues or Issue Description No public GitHub issue exists. ### Subsystem affected Cross-cutting: `server/` custom image setup APIs and websocket handling, `ui/` environment configuration UI, and shared custom image contracts. ### Problem or motivation Custom image creation and refresh require an operator to open a separate SSH client, paste the command shown by Paperclip, perform setup work, then return to the browser to finish the image flow. This is functional but awkward for a setup process that already starts and tracks a temporary sandbox session. ### Proposed solution Embed an SSH terminal in the custom image setup UI. When a setup session exposes an SSH payload, Paperclip should open a browser terminal backed by a server-side websocket session, let the operator run setup commands in-place, and then close the terminal when setup is finished, cancelled, expired, or disconnected. ### Alternatives considered - Keep the existing copy/paste SSH command workflow. This remains a fallback, but it does not streamline the common path. - Put SSH credentials directly into websocket URLs. This was avoided so terminal authentication can happen in an explicit first websocket auth frame rather than in logged URLs. - Trust the SSH host blindly for every reconnect. This PR instead pins the observed host-key fingerprint for the setup-session lifetime. ### Roadmap alignment This fits the roadmap theme of making agent workspaces usable in more remote and sandboxed environments while preserving Paperclip's control-plane model. ### Additional context Public GitHub search did not find a duplicate issue or PR for `custom image terminal ssh` in `paperclipai/paperclip`. ## What Changed - Added server-side terminal session tracking for custom image setup sessions, including connect-token issuance, websocket attachment, expiry, resize, input, and shutdown handling. - Added an embedded browser terminal to the custom image creation and refresh flow when a setup session provides SSH connection details. - Moved terminal token authentication out of the websocket URL and into the first websocket JSON auth frame. - Added SSH host-key SHA-256 pinning for each terminal session and documented the provider convention for username-embedded SSH credentials. - Updated the custom image environment API and UI so the setup terminal can open, reconnect, show status, authenticate, resize, and remain active for the setup-session lifetime once attached. - Kept custom image setup routes company-scoped and closed active terminal sessions on setup finish/cancel. - Added focused unit/integration/UI coverage for token expiry, setup-session expiry, websocket close paths, host-key pinning, and terminal session lifecycle behavior. - Removed the generated lockfile delta from the PR; CI owns temporary lockfile regeneration for manifest-changing PRs. ## Verification - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/environment-custom-image-terminal-ws.test.ts server/src/services/environment-custom-image-terminal-sessions.test.ts server/src/__tests__/environment-custom-image-routes.test.ts packages/shared/src/environment-custom-images.test.ts ui/src/pages/CompanyEnvironments.test.tsx` - 6 test files passed - 58 tests passed - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server build` - `pnpm --filter @paperclipai/ui build` - `pnpm run typecheck:build-gaps` - `git diff --check` - Local sensitive-content scan over the PR diff using patterns for API keys, private keys, private hostnames, local paths, token fields, and credential-like strings. - Findings were limited to removed URL-token code and synthetic test placeholders such as `ssh-token-secret` and `terminal-token-terminal-token-123456`. - No real credentials, private hostnames, local filesystem paths, or instance-local links were found. - Remote PR checks were green after the implementation commit, including Build, Typecheck + Release Registry, General tests, serialized server suites, e2e, verify, Socket, Snyk, Superagent, and Greptile 5/5. - Post-merge PR hardening on July 3, 2026: merged `origin/master` at `47448721e` into the branch, resolved the `CompanyEnvironments.tsx` import conflict, reran focused tests, server/UI typechecks, server/UI builds, `pnpm run typecheck:build-gaps`, and `git diff --check`, scanned the final diff for sensitive content, pushed `4b43558cc`, and confirmed all remote checks plus Greptile 5/5 were green. - PR metadata correction on July 3, 2026: changed the title/body framing from bug-fix language to feature-request language. No source files changed for this metadata-only update. ## Risks - Moderate surface area because this adds websocket routing, setup-session runtime state, package dependencies, and a new custom image UI path. - New websocket attachments still require valid short-lived tokens; established terminal sessions remain bounded by setup-session expiry, explicit finish/cancel, client close, or server shutdown. - The terminal-session store is in-memory, so active terminal websocket tokens and host-key pins do not survive server restarts. - SSH host-key verification uses session-lifetime TOFU pinning because the current provider payload does not expose a trusted host-key fingerprint. - The external SSH command remains important as a fallback if a browser, proxy, or network environment cannot sustain the websocket terminal. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent with shell/tool execution. Context window size was not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/api.ts | 2 + .../src/environment-custom-images.test.ts | 8 +- .../shared/src/environment-custom-images.ts | 14 +- packages/shared/src/index.ts | 4 + .../validators/environment-custom-images.ts | 16 + packages/shared/src/validators/index.ts | 4 + server/package.json | 1 + .../environment-custom-image-routes.test.ts | 247 +++++- ...vironment-custom-image-terminal-ws.test.ts | 565 +++++++++++++ server/src/index.ts | 4 + .../environment-custom-image-terminal-ws.ts | 766 ++++++++++++++++++ server/src/realtime/live-events-ws.ts | 5 + server/src/routes/environments.ts | 179 +++- server/src/routes/openapi.ts | 22 + ...onment-custom-image-setup-session-utils.ts | 32 + ...ent-custom-image-terminal-sessions.test.ts | 262 ++++++ ...ironment-custom-image-terminal-sessions.ts | 353 ++++++++ .../src/services/environment-custom-images.ts | 2 +- server/src/services/index.ts | 11 + ui/package.json | 2 + ui/src/api/environments.ts | 10 + ui/src/pages/CompanyEnvironments.test.tsx | 367 +++++++++ ui/src/pages/CompanyEnvironments.tsx | 458 ++++++++++- 23 files changed, 3303 insertions(+), 31 deletions(-) create mode 100644 server/src/__tests__/environment-custom-image-terminal-ws.test.ts create mode 100644 server/src/realtime/environment-custom-image-terminal-ws.ts create mode 100644 server/src/services/environment-custom-image-setup-session-utils.ts create mode 100644 server/src/services/environment-custom-image-terminal-sessions.test.ts create mode 100644 server/src/services/environment-custom-image-terminal-sessions.ts diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 5ed37e0ea1..e387809700 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -11,6 +11,8 @@ export const API = { environmentCustomImageTemplateRollback: `${API_PREFIX}/environments/:environmentId/custom-image-template/rollback`, environmentCustomImageSetupSessions: `${API_PREFIX}/environments/:environmentId/custom-image-setup-sessions`, environmentCustomImageSetupSession: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId`, + environmentCustomImageSetupSessionTerminalToken: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/terminal-session-token`, + environmentCustomImageSetupSessionTerminalWs: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/terminal/ws`, environmentCustomImageSetupSessionFinish: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/finish`, environmentCustomImageSetupSessionCancel: `${API_PREFIX}/environment-custom-image-setup-sessions/:sessionId/cancel`, issues: `${API_PREFIX}/issues`, diff --git a/packages/shared/src/environment-custom-images.test.ts b/packages/shared/src/environment-custom-images.test.ts index e102d95e5c..80d00f3581 100644 --- a/packages/shared/src/environment-custom-images.test.ts +++ b/packages/shared/src/environment-custom-images.test.ts @@ -99,6 +99,9 @@ describe("environment customImage redaction", () => { metadata: { safeLabel: "codex template", apiToken: "token-value", + userMetadata: { + safe: "kept", + }, nested: { host: "203.0.113.10", safe: "kept", @@ -111,6 +114,9 @@ describe("environment customImage redaction", () => { expect(redacted.metadata).toEqual({ safeLabel: "codex template", apiToken: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, + userMetadata: { + safe: "kept", + }, nested: { host: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, safe: "kept", @@ -140,7 +146,7 @@ describe("environment customImage redaction", () => { expect(redacted.connectionSecretRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE); expect(redacted.connectionSummary).toEqual({ type: "ssh", - username: "sandbox", + username: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, hostRedacted: true, portRedacted: true, instructions: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, diff --git a/packages/shared/src/environment-custom-images.ts b/packages/shared/src/environment-custom-images.ts index be4cca5f61..a81814b7f9 100644 --- a/packages/shared/src/environment-custom-images.ts +++ b/packages/shared/src/environment-custom-images.ts @@ -86,6 +86,16 @@ export interface EnvironmentCustomImageSetupSessionRedactionInput { export function redactEnvironmentCustomImageSetupSession< T extends EnvironmentCustomImageSetupSessionRedactionInput, >(session: T): T { + const connectionSummary = session.connectionSummary == null + ? session.connectionSummary + : { + ...redactEnvironmentCustomImageValue(session.connectionSummary), + ...( + Object.prototype.hasOwnProperty.call(session.connectionSummary, "username") + ? { username: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE } + : {} + ), + }; return { ...session, providerLeaseId: session.providerLeaseId == null @@ -97,9 +107,7 @@ export function redactEnvironmentCustomImageSetupSession< connectionSecretRef: session.connectionSecretRef == null ? session.connectionSecretRef : REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE, - connectionSummary: session.connectionSummary == null - ? session.connectionSummary - : redactEnvironmentCustomImageValue(session.connectionSummary), + connectionSummary, metadata: session.metadata == null ? session.metadata : redactEnvironmentCustomImageValue(session.metadata), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a943ed8719..376ef01383 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1620,10 +1620,14 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + createEnvironmentCustomImageTerminalSessionTokenSchema, + environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, type EnvironmentCustomImageTemplate, type EnvironmentCustomImageSetupSession, type StartEnvironmentCustomImageSetupSession, type FinishEnvironmentCustomImageSetupSession, type CancelEnvironmentCustomImageSetupSession, + type CreateEnvironmentCustomImageTerminalSessionToken, + type EnvironmentCustomImageTerminalSessionToken, } from "./validators/environment-custom-images.js"; diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts index bd1b76a6c1..07eb945f20 100644 --- a/packages/shared/src/validators/environment-custom-images.ts +++ b/packages/shared/src/validators/environment-custom-images.ts @@ -93,3 +93,19 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({ }).strict(); export type CancelEnvironmentCustomImageSetupSession = z.infer; + +export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({}); +export type CreateEnvironmentCustomImageTerminalSessionToken = + z.infer; + +export const environmentCustomImageTerminalSessionTokenSchema = z.object({ + id: z.string().min(1), + token: z.string().min(32), + expiresAt: isoDateTime, + setupSessionId: z.string().min(1), + environmentId: z.string().min(1), + connectionType: z.literal("ssh"), + websocketPath: z.string().min(1), +}).strict(); +export type EnvironmentCustomImageTerminalSessionToken = + z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 6bf6f3e4de..2ddcca2ac2 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -52,12 +52,16 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + createEnvironmentCustomImageTerminalSessionTokenSchema, + environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, type EnvironmentCustomImageTemplate, type EnvironmentCustomImageSetupSession, type StartEnvironmentCustomImageSetupSession, type FinishEnvironmentCustomImageSetupSession, type CancelEnvironmentCustomImageSetupSession, + type CreateEnvironmentCustomImageTerminalSessionToken, + type EnvironmentCustomImageTerminalSessionToken, } from "./environment-custom-images.js"; export { feedbackDataSharingPreferenceSchema, diff --git a/server/package.json b/server/package.json index cc41dc5818..ca8fbb6956 100644 --- a/server/package.json +++ b/server/package.json @@ -77,6 +77,7 @@ "pino-http": "^10.4.0", "pino-pretty": "^13.1.3", "sharp": "^0.35.2", + "ssh2": "^1.17.0", "ws": "^8.19.0", "zod": "^3.24.2" }, diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts index 6d3268c78f..ae20e42526 100644 --- a/server/src/__tests__/environment-custom-image-routes.test.ts +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -2,6 +2,10 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { environmentRoutes } from "../routes/environments.js"; +import { + environmentCustomImageTerminalConnectionRegistry, + environmentCustomImageTerminalSessionStore, +} from "../services/environment-custom-image-terminal-sessions.js"; const now = new Date("2026-06-25T20:00:00.000Z"); @@ -120,6 +124,7 @@ function createEnvironment(overrides: Record = {}) { function createTemplate(overrides: Record = {}) { return { id: "template-1", + companyId: "company-1", environmentId: "env-1", provider: "daytona", templateKind: "snapshot", @@ -142,6 +147,7 @@ function createTemplate(overrides: Record = {}) { function createSession(overrides: Record = {}) { return { id: "session-1", + companyId: "company-1", environmentId: "env-1", templateId: "template-1", promotedTemplateId: null, @@ -164,6 +170,7 @@ function createSession(overrides: Record = {}) { }, connectionSecretRef: null, metadata: { + setupRpcCompanyId: "company-1", safeLabel: "setup", connectUrl: "https://203.0.113.10/setup", }, @@ -214,6 +221,10 @@ function loggedActivityJson() { return JSON.stringify(mockLogActivity.mock.calls); } +function futureDate(minutes = 60) { + return new Date(Date.now() + minutes * 60 * 1000); +} + describe("environment customImage setup routes", () => { beforeEach(() => { mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); @@ -224,6 +235,8 @@ describe("environment customImage setup routes", () => { mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); Object.values(mockSecretService).forEach((mock) => mock.mockReset()); mockLogActivity.mockReset(); + environmentCustomImageTerminalSessionStore.clear(); + environmentCustomImageTerminalConnectionRegistry.clear(); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); @@ -280,7 +293,7 @@ describe("environment customImage setup routes", () => { it("starts a setup session, returns the live payload, and logs redacted details", async () => { const res = await request(createApp(boardActor())) - .post("/api/environments/env-1/custom-image-setup-sessions") + .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") .send({ ttlSeconds: 3600 }); expect(res.status).toBe(201); @@ -293,6 +306,7 @@ describe("environment customImage setup routes", () => { userId: "user-1", agentId: null, }, + secretContextCompanyId: "company-1", }); const activity = loggedActivityJson(); expect(activity).not.toContain("203.0.113.10"); @@ -314,10 +328,202 @@ describe("environment customImage setup routes", () => { }); }); + it("mints a redacted terminal token for waiting SSH setup sessions", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession({ + expiresAt: futureDate(), + })); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValue({ + session: createSession({ + expiresAt: futureDate(), + connectionSummary: { + type: "ssh", + username: "ssh-token-secret", + hostRedacted: true, + portRedacted: true, + instructions: "ssh ssh-token-secret@203.0.113.10 -p 2222", + }, + }), + connectionPayload: { + type: "ssh", + command: "ssh ssh-token-secret@203.0.113.10 -p 2222", + expiresAt: futureDate(15).toISOString(), + }, + }); + + const res = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + setupSessionId: "session-1", + environmentId: "env-1", + connectionType: "ssh", + }); + expect(typeof res.body.id).toBe("string"); + expect(typeof res.body.token).toBe("string"); + expect(typeof res.body.expiresAt).toBe("string"); + expect(res.body.websocketPath).toContain( + `/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=${encodeURIComponent(res.body.id)}`, + ); + expect(res.body.websocketPath).not.toContain("token="); + expect(res.body.websocketPath).not.toContain(res.body.token); + expect(mockEnvironmentCustomImageService.refreshSetupSession).toHaveBeenCalledWith({ + sessionId: "session-1", + includeConnectionPayload: true, + }); + const responseJson = JSON.stringify(res.body); + expect(responseJson).not.toContain("ssh-token-secret"); + expect(responseJson).not.toContain("203.0.113.10"); + expect(responseJson).not.toContain("ssh "); + const activity = loggedActivityJson(); + expect(activity).not.toContain("ssh-token-secret"); + expect(activity).not.toContain("203.0.113.10"); + expect(activity).not.toContain("ssh "); + }); + + it("denies terminal token minting to agent API key actors before customImage state is read", async () => { + const res = await request(createApp(agentActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.getSessionById).not.toHaveBeenCalled(); + expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); + }); + + it("denies terminal token minting to non-admin board users before connection payload refresh", async () => { + const res = await request(createApp(boardActor({ + companyIds: ["company-2"], + isInstanceAdmin: false, + }))) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.getSessionById).not.toHaveBeenCalled(); + expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); + }); + + it("rejects terminal tokens unless the refreshed setup session is waiting for the user", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession({ + expiresAt: futureDate(), + })); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValue({ + session: createSession({ status: "starting", expiresAt: futureDate() }), + connectionPayload: { + type: "ssh", + command: "ssh user@example.test", + }, + }); + + const res = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(res.status).toBe(409); + expect(JSON.stringify(res.body)).not.toContain("user@example.test"); + }); + + it("rejects terminal tokens for expired setup sessions", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValue({ + session: createSession({ + status: "waiting_for_user", + expiresAt: new Date("2026-06-25T19:00:00.000Z"), + }), + connectionPayload: { + type: "ssh", + command: "ssh user@example.test", + }, + }); + + const res = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(res.status).toBe(409); + }); + + it("rejects non-SSH or unsupported SSH terminal payloads without echoing secrets", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession({ + expiresAt: futureDate(), + })); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValueOnce({ + session: createSession({ expiresAt: futureDate() }), + connectionPayload: { + type: "browser_terminal", + command: "ssh ssh-token-secret@203.0.113.10", + }, + }); + + const unsupportedType = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(unsupportedType.status).toBe(422); + expect(JSON.stringify(unsupportedType.body)).not.toContain("ssh-token-secret"); + expect(JSON.stringify(unsupportedType.body)).not.toContain("203.0.113.10"); + + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValueOnce({ + session: createSession({ expiresAt: futureDate() }), + connectionPayload: { + type: "ssh", + command: "ssh ssh-token-secret@203.0.113.10 -i /tmp/private-key", + }, + }); + + const unsupportedShape = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(unsupportedShape.status).toBe(422); + expect(JSON.stringify(unsupportedShape.body)).not.toContain("ssh-token-secret"); + expect(JSON.stringify(unsupportedShape.body)).not.toContain("203.0.113.10"); + expect(loggedActivityJson()).not.toContain("ssh-token-secret"); + }); + + it("rejects invalid and expired terminal payload expiries without minting tokens", async () => { + mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession({ + expiresAt: futureDate(), + })); + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValueOnce({ + session: createSession({ expiresAt: futureDate() }), + connectionPayload: { + type: "ssh", + command: "ssh ssh-token-secret@ssh.app.daytona.io", + expiresAt: "not-a-date", + }, + }); + + const invalidExpiry = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(invalidExpiry.status).toBe(422); + expect(JSON.stringify(invalidExpiry.body)).not.toContain("ssh-token-secret"); + + mockEnvironmentCustomImageService.refreshSetupSession.mockResolvedValueOnce({ + session: createSession({ expiresAt: futureDate() }), + connectionPayload: { + type: "ssh", + command: "ssh ssh-token-secret@ssh.app.daytona.io", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }, + }); + + const expiredPayload = await request(createApp(boardActor())) + .post("/api/environment-custom-image-setup-sessions/session-1/terminal-session-token") + .send({}); + + expect(expiredPayload.status).toBe(409); + expect(JSON.stringify(expiredPayload.body)).not.toContain("ssh-token-secret"); + }); + it("denies agent API key actors before customImage state or payloads are read", async () => { const app = createApp(agentActor()); const start = await request(app) - .post("/api/environments/env-1/custom-image-setup-sessions") + .post("/api/environments/env-1/custom-image-setup-sessions?companyId=company-1") .send({}); const status = await request(app) .get("/api/environment-custom-image-setup-sessions/session-1"); @@ -343,21 +549,35 @@ describe("environment customImage setup routes", () => { expect(mockEnvironmentCustomImageService.refreshSetupSession).not.toHaveBeenCalled(); }); - it("does not require a company fallback to start setup", async () => { + it("denies single-company fallback when the board actor is not a member", async () => { mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-2"]); - const res = await request(createApp(boardActor())) + const res = await request(createApp(boardActor({ + companyIds: ["company-1"], + isInstanceAdmin: false, + }))) .post("/api/environments/env-1/custom-image-setup-sessions") .send({}); - expect(res.status).toBe(201); - expect(mockEnvironmentCustomImageService.startSetupSession).toHaveBeenCalledWith(expect.objectContaining({ - environmentId: "env-1", - })); + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.startSetupSession).not.toHaveBeenCalled(); }); it("finishes and promotes a template while logging redacted template details", async () => { mockEnvironmentCustomImageService.getSessionById.mockResolvedValue(createSession()); + const terminal = environmentCustomImageTerminalSessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "token-secret", host: "203.0.113.10", port: 2222 }, + setupExpiresAt: futureDate(), + }); + const closeReasons: string[] = []; + environmentCustomImageTerminalConnectionRegistry.add({ + setupSessionId: "session-1", + close: (reason) => closeReasons.push(reason), + }); const res = await request(createApp(boardActor())) .post("/api/environment-custom-image-setup-sessions/session-1/finish") @@ -369,6 +589,11 @@ describe("environment customImage setup routes", () => { sessionId: "session-1", metadata: { safeLabel: "done" }, }); + expect(environmentCustomImageTerminalSessionStore.get({ + id: terminal.session.id, + token: terminal.token, + })).toBeNull(); + expect(closeReasons).toEqual(["setup_finished"]); const activity = loggedActivityJson(); expect(activity).not.toContain("captured-template-secret"); expect(activity).not.toContain("snapshot-secret-ref"); @@ -391,13 +616,13 @@ describe("environment customImage setup routes", () => { expect(loggedActivityJson()).not.toContain("lease-secret"); }); - it("rolls back and disables active templates through instance-scoped routes", async () => { + it("rolls back and disables active templates through company-scoped routes", async () => { const app = createApp(boardActor()); const rollback = await request(app) - .post("/api/environments/env-1/custom-image-template/rollback") + .post("/api/environments/env-1/custom-image-template/rollback?companyId=company-1") .send({}); const disable = await request(app) - .delete("/api/environments/env-1/custom-image-template?deleteProviderTemplate=true"); + .delete("/api/environments/env-1/custom-image-template?companyId=company-1&deleteProviderTemplate=true"); expect(rollback.status).toBe(200); expect(disable.status).toBe(200); diff --git a/server/src/__tests__/environment-custom-image-terminal-ws.test.ts b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts new file mode 100644 index 0000000000..20122674c4 --- /dev/null +++ b/server/src/__tests__/environment-custom-image-terminal-ws.test.ts @@ -0,0 +1,565 @@ +import { EventEmitter } from "node:events"; +import { createServer, type Server as HttpServer } from "node:http"; +import { createRequire } from "node:module"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../services/environment-custom-images.js", () => ({ + environmentCustomImageService: vi.fn(() => { + throw new Error("test must inject a custom image service"); + }), +})); + +import { + setupEnvironmentCustomImageTerminalWebSocketServer, + type EnvironmentCustomImageSshConnector, + type EnvironmentCustomImageSshShell, +} from "../realtime/environment-custom-image-terminal-ws.js"; +import { + EnvironmentCustomImageTerminalConnectionRegistry, + EnvironmentCustomImageTerminalSessionStore, +} from "../services/environment-custom-image-terminal-sessions.js"; + +const require = createRequire(import.meta.url); +const { WebSocket } = require("ws") as { + WebSocket: new (url: string) => { + readyState: number; + send(data: string): void; + close(): void; + on(event: "open", listener: () => void): void; + on(event: "message", listener: (data: Buffer | string) => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + }; +}; + +class FakeSshShell extends EventEmitter implements EnvironmentCustomImageSshShell { + writes: string[] = []; + resizes: Array<{ cols: number; rows: number }> = []; + closeCalls = 0; + + write(data: string): void { + this.writes.push(data); + } + + resize(cols: number, rows: number): void { + this.resizes.push({ cols, rows }); + } + + close(): void { + this.closeCalls += 1; + } + + onData(listener: (data: string) => void): void { + this.on("data", listener); + } + + onClose(listener: () => void): void { + this.on("close", listener); + } + + onError(listener: (err: Error) => void): void { + this.on("ssh-error", listener); + } + + emitData(data: string) { + this.emit("data", data); + } + + emitSshClose() { + this.emit("close"); + } + + emitSshError(err: Error) { + this.emit("ssh-error", err); + } +} + +function futureDate(minutes = 60) { + return new Date(Date.now() + minutes * 60 * 1000); +} + +function createSession(overrides: Record = {}) { + return { + id: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + status: "waiting_for_user", + expiresAt: futureDate(), + metadata: { setupRpcCompanyId: "company-1" }, + ...overrides, + }; +} + +async function flushPromises() { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function waitForAssertion(assertion: () => void) { + let lastError: unknown; + for (let i = 0; i < 20; i += 1) { + await flushPromises(); + try { + assertion(); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +async function waitForDuration(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (err: Error) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function listen(server: HttpServer) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not listen on a TCP port"); + return address.port; +} + +async function closeServer(server: HttpServer) { + if (!server.listening) return; + await new Promise((resolve) => server.close(() => resolve())); +} + +function waitForOpen(ws: InstanceType) { + return new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); +} + +function waitForClose(ws: InstanceType) { + return new Promise((resolve) => { + ws.on("close", resolve); + }); +} + +function waitForJsonMessage>( + ws: InstanceType, + predicate: (frame: T) => boolean, +) { + return new Promise((resolve) => { + ws.on("message", (data: Buffer | string) => { + const text = typeof data === "string" ? data : data.toString("utf8"); + const parsed = JSON.parse(text) as T; + if (predicate(parsed)) resolve(parsed); + }); + }); +} + +function sendTerminalAuth(ws: InstanceType, token: string) { + ws.send(JSON.stringify({ type: "auth", token })); +} + +function terminalUrl(port: number, input: { setupSessionId?: string; terminalSessionId: string }) { + const setupSessionId = input.setupSessionId ?? "session-1"; + return `ws://127.0.0.1:${port}/api/environment-custom-image-setup-sessions/${setupSessionId}/terminal/ws` + + `?terminalSessionId=${encodeURIComponent(input.terminalSessionId)}` + + "&cols=100&rows=30"; +} + +describe("custom image terminal websocket bridge", () => { + let servers: HttpServer[] = []; + let sessionStore: EnvironmentCustomImageTerminalSessionStore; + let connectionRegistry: EnvironmentCustomImageTerminalConnectionRegistry; + let fakeShell: FakeSshShell; + let customImages: { + getSessionById: ReturnType; + refreshSetupSession: ReturnType; + }; + let connector: EnvironmentCustomImageSshConnector & { connect: ReturnType }; + + beforeEach(() => { + servers = []; + sessionStore = new EnvironmentCustomImageTerminalSessionStore(); + connectionRegistry = new EnvironmentCustomImageTerminalConnectionRegistry(); + fakeShell = new FakeSshShell(); + customImages = { + getSessionById: vi.fn(async () => createSession()), + refreshSetupSession: vi.fn(async () => ({ + session: createSession(), + connectionPayload: { + type: "ssh", + command: "ssh fresh-token@fresh.example.test -p 2200", + expiresAt: futureDate(30).toISOString(), + }, + })), + }; + connector = { + connect: vi.fn(async () => fakeShell), + }; + }); + + afterEach(async () => { + for (const server of servers) { + server.emit("close"); + } + await Promise.all(servers.map((server) => closeServer(server))); + }); + + async function startHarness() { + const server = createServer(); + servers.push(server); + setupEnvironmentCustomImageTerminalWebSocketServer(server, {} as never, { + customImageService: customImages, + sessionStore, + connectionRegistry, + sshConnector: connector, + }); + const port = await listen(server); + return { server, port }; + } + + it("rejects invalid and expired terminal credentials before refreshing provider payloads", async () => { + const { port } = await startHarness(); + const invalid = new WebSocket(terminalUrl(port, { + terminalSessionId: "missing", + })); + + const invalidError = waitForJsonMessage(invalid, (frame) => frame.type === "error"); + const invalidClose = waitForClose(invalid); + await waitForOpen(invalid); + sendTerminalAuth(invalid, "bad-token"); + await expect(invalidError).resolves.toMatchObject({ + type: "error", + message: "Invalid terminal session token.", + }); + await invalidClose; + expect(customImages.refreshSetupSession).not.toHaveBeenCalled(); + + const expired = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old", host: "old.example.test", port: 22 }, + setupExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + connectionExpiresAt: new Date(Date.now() - 60 * 1000), + now: new Date(Date.now() - 2 * 60 * 1000), + }); + const expiredWs = new WebSocket(terminalUrl(port, { + terminalSessionId: expired.session.id, + })); + + const expiredError = waitForJsonMessage(expiredWs, (frame) => frame.type === "error"); + const expiredClose = waitForClose(expiredWs); + await waitForOpen(expiredWs); + sendTerminalAuth(expiredWs, expired.token); + await expect(expiredError).resolves.toMatchObject({ + type: "error", + message: "Invalid terminal session token.", + }); + await expiredClose; + expect(customImages.refreshSetupSession).not.toHaveBeenCalled(); + }); + + it("rejects unsupported refreshed payloads before opening an SSH bridge", async () => { + const { port } = await startHarness(); + customImages.refreshSetupSession.mockResolvedValueOnce({ + session: createSession(), + connectionPayload: { + type: "browser_terminal", + command: "ssh provider-secret@203.0.113.10", + }, + }); + const unsupportedPayload = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + + const unsupportedWs = new WebSocket(terminalUrl(port, { + terminalSessionId: unsupportedPayload.session.id, + })); + const unsupportedError = waitForJsonMessage(unsupportedWs, (frame) => frame.type === "error"); + const unsupportedClose = waitForClose(unsupportedWs); + await waitForOpen(unsupportedWs); + sendTerminalAuth(unsupportedWs, unsupportedPayload.token); + await expect(unsupportedError).resolves.toMatchObject({ + type: "error", + message: "Setup session terminal connections require an SSH connection payload.", + }); + await unsupportedClose; + expect(sessionStore.get({ + id: unsupportedPayload.session.id, + token: unsupportedPayload.token, + })).toBeNull(); + + customImages.refreshSetupSession.mockResolvedValueOnce({ + session: createSession(), + connectionPayload: { + type: "ssh", + command: "ssh provider-secret@203.0.113.10 -i /tmp/private-key", + }, + }); + const unsupportedCommand = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + + const unsupportedCommandWs = new WebSocket(terminalUrl(port, { + terminalSessionId: unsupportedCommand.session.id, + })); + const unsupportedCommandError = waitForJsonMessage(unsupportedCommandWs, (frame) => frame.type === "error"); + const unsupportedCommandClose = waitForClose(unsupportedCommandWs); + await waitForOpen(unsupportedCommandWs); + sendTerminalAuth(unsupportedCommandWs, unsupportedCommand.token); + await expect(unsupportedCommandError).resolves.toMatchObject({ + type: "error", + message: "Setup session SSH payload uses an unsupported command shape.", + }); + await unsupportedCommandClose; + expect(sessionStore.get({ + id: unsupportedCommand.session.id, + token: unsupportedCommand.token, + })).toBeNull(); + expect(connector.connect).not.toHaveBeenCalled(); + }); + + it("bridges websocket input, SSH output, and resize frames through a fake shell", async () => { + const { port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + const ready = await readyPromise; + expect(ready).toMatchObject({ + type: "ready", + setupSessionId: "session-1", + terminalSessionId: minted.session.id, + }); + expect(customImages.refreshSetupSession).toHaveBeenCalledWith({ + sessionId: "session-1", + includeConnectionPayload: true, + }); + expect(connector.connect).toHaveBeenCalledWith({ + ssh: { username: "fresh-token", host: "fresh.example.test", port: 2200 }, + term: "xterm-256color", + cols: 100, + rows: 30, + verifyHostKeySha256: expect.any(Function), + }); + + ws.send(JSON.stringify({ type: "input", data: "echo ok\r" })); + await waitForAssertion(() => { + expect(fakeShell.writes).toEqual(["echo ok\r"]); + }); + + const outputPromise = waitForJsonMessage(ws, (frame) => frame.type === "output"); + fakeShell.emitData("shell output\r\n"); + await expect(outputPromise).resolves.toMatchObject({ + type: "output", + data: "shell output\r\n", + }); + + ws.send(JSON.stringify({ type: "resize", cols: 120, rows: 40 })); + await waitForAssertion(() => { + expect(fakeShell.resizes).toEqual([{ cols: 120, rows: 40 }]); + }); + + const closePromise = waitForClose(ws); + ws.close(); + await closePromise; + await waitForAssertion(() => { + expect(fakeShell.closeCalls).toBeGreaterThan(0); + expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + }); + }); + + it("keeps established terminal sessions alive past connect-token expiry and closes them at setup expiry", async () => { + const setupExpiresAt = new Date(Date.now() + 2500); + customImages.getSessionById.mockResolvedValue(createSession({ expiresAt: setupExpiresAt })); + customImages.refreshSetupSession.mockResolvedValue({ + session: createSession({ expiresAt: setupExpiresAt }), + connectionPayload: { + type: "ssh", + command: "ssh fresh-token@fresh.example.test -p 2200", + expiresAt: futureDate(30).toISOString(), + }, + }); + const { port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt, + now: new Date(Date.now() - 5 * 60 * 1000 + 750), + }); + expect(minted.session.connectExpiresAt.getTime()).toBeLessThan(minted.session.sessionExpiresAt.getTime()); + + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + let closed = false; + ws.on("close", () => { + closed = true; + }); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + const closePromise = waitForClose(ws); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await readyPromise; + await waitForDuration(Math.max(0, minted.session.connectExpiresAt.getTime() - Date.now()) + 400); + expect(closed).toBe(false); + expect(fakeShell.closeCalls).toBe(0); + + await closePromise; + expect(closed).toBe(true); + expect(fakeShell.closeCalls).toBeGreaterThan(0); + }); + + it("applies the latest resize sent while the SSH shell is still opening", async () => { + const pendingShell = deferred(); + connector.connect.mockReturnValueOnce(pendingShell.promise); + const { port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await waitForAssertion(() => { + expect(connector.connect).toHaveBeenCalled(); + }); + const unsupportedFramePromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); + ws.send(JSON.stringify({ type: "resize", cols: 110, rows: 31 })); + ws.send(JSON.stringify({ type: "resize", cols: 132, rows: 43 })); + ws.send(JSON.stringify({ type: "unsupported-test-frame" })); + await unsupportedFramePromise; + expect(fakeShell.resizes).toEqual([]); + + pendingShell.resolve(fakeShell); + await readyPromise; + expect(fakeShell.resizes).toEqual([{ cols: 132, rows: 43 }]); + + ws.close(); + await waitForClose(ws); + }); + + it("sends a redacted fallback error when SSH bridge connection fails", async () => { + const { port } = await startHarness(); + connector.connect.mockRejectedValueOnce(new Error("provider secret should not leak")); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "old-token", host: "old.example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + const errorPromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); + const closePromise = waitForClose(ws); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await expect(errorPromise).resolves.toMatchObject({ + type: "error", + message: "SSH terminal connection failed.", + }); + await closePromise; + expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + }); + + it("cleans up and closes the websocket when the SSH shell errors", async () => { + const { port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "token", host: "example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await readyPromise; + const errorPromise = waitForJsonMessage(ws, (frame) => frame.type === "error"); + const closePromise = waitForClose(ws); + fakeShell.emitSshError(new Error("provider secret should not leak")); + await expect(errorPromise).resolves.toMatchObject({ + type: "error", + message: "SSH terminal connection failed.", + }); + await closePromise; + expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + expect(fakeShell.closeCalls).toBeGreaterThan(0); + }); + + it("closes active terminal sessions on server shutdown", async () => { + const { server, port } = await startHarness(); + const minted = sessionStore.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "token", host: "example.test", port: 22 }, + setupExpiresAt: futureDate(), + }); + const ws = new WebSocket(terminalUrl(port, { + terminalSessionId: minted.session.id, + })); + const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready"); + + await waitForOpen(ws); + sendTerminalAuth(ws, minted.token); + await readyPromise; + const closePromise = waitForClose(ws); + server.emit("close"); + await closePromise; + + expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull(); + expect(fakeShell.closeCalls).toBeGreaterThan(0); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 93e867f895..7254ee5366 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -33,6 +33,7 @@ import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; +import { setupEnvironmentCustomImageTerminalWebSocketServer } from "./realtime/environment-custom-image-terminal-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, @@ -700,6 +701,9 @@ export async function startServer(): Promise { process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify(runtimeApiCandidates); process.env.PAPERCLIP_API_URL = configuredApiUrl; + setupEnvironmentCustomImageTerminalWebSocketServer(server, db as any, { + pluginWorkerManager, + }); setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, resolveSessionFromHeaders, diff --git a/server/src/realtime/environment-custom-image-terminal-ws.ts b/server/src/realtime/environment-custom-image-terminal-ws.ts new file mode 100644 index 0000000000..833ccea52a --- /dev/null +++ b/server/src/realtime/environment-custom-image-terminal-ws.ts @@ -0,0 +1,766 @@ +import type { IncomingMessage, Server as HttpServer } from "node:http"; +import { createRequire } from "node:module"; +import type { Duplex } from "node:stream"; +import type { Db } from "@paperclipai/db"; +import { conflict, unprocessable } from "../errors.js"; +import { logger } from "../middleware/logger.js"; +import { + readCustomImageSetupSessionCompanyId, + requireFutureCustomImageSetupExpiry, +} from "../services/environment-custom-image-setup-session-utils.js"; +import { environmentCustomImageService } from "../services/environment-custom-images.js"; +import { + environmentCustomImageTerminalConnectionRegistry, + environmentCustomImageTerminalSessionStore, + validateCustomImageSetupSshPayload, + type EnvironmentCustomImageTerminalConnectionRegistry, + type EnvironmentCustomImageTerminalPayloadValidationResult, + type EnvironmentCustomImageTerminalSessionRecord, + type EnvironmentCustomImageTerminalSessionStore, + type ParsedCustomImageSetupSshCommand, +} from "../services/environment-custom-image-terminal-sessions.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; + +interface TerminalWsSocket { + readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + terminate(): void; + on(event: "message", listener: (data: unknown) => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; +} + +interface TerminalWsServer { + clients: Set; + on(event: "connection", listener: (socket: TerminalWsSocket, req: IncomingMessage) => void): void; + on(event: "close", listener: () => void): void; + close(callback?: (err?: Error) => void): void; + handleUpgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + callback: (ws: TerminalWsSocket) => void, + ): void; + emit(event: "connection", ws: TerminalWsSocket, req: IncomingMessage): boolean; +} + +interface SetupSessionSnapshot { + id: string; + environmentId: string; + provider: string; + status: string; + expiresAt: Date | string | null; + metadata?: Record | null; +} + +interface CustomImageTerminalService { + getSessionById(sessionId: string): Promise; + refreshSetupSession(input: { + sessionId: string; + includeConnectionPayload: true; + }): Promise<{ + session: SetupSessionSnapshot; + connectionPayload: unknown; + }>; +} + +export interface EnvironmentCustomImageSshShell { + write(data: string): void; + resize(cols: number, rows: number): void; + close(): void; + onData(listener: (data: string) => void): void; + onClose(listener: () => void): void; + onError(listener: (err: Error) => void): void; +} + +export interface EnvironmentCustomImageSshConnector { + connect(input: { + ssh: ParsedCustomImageSetupSshCommand; + term: string; + cols: number; + rows: number; + verifyHostKeySha256: (hostKeySha256: string) => boolean; + }): Promise; +} + +interface TerminalUpgradeContext { + setupSessionId: string; + terminalSessionId: string; + initialCols: number; + initialRows: number; +} + +interface AuthenticatedTerminalContext { + setupSessionId: string; + terminalSession: EnvironmentCustomImageTerminalSessionRecord; + ssh: ParsedCustomImageSetupSshCommand; + initialCols: number; + initialRows: number; +} + +interface IncomingMessageWithTerminalContext extends IncomingMessage { + paperclipWebSocketHandled?: boolean; + paperclipTerminalUpgradeContext?: TerminalUpgradeContext; +} + +const require = createRequire(import.meta.url); +const { WebSocket, WebSocketServer } = require("ws") as { + WebSocket: { OPEN: number }; + WebSocketServer: new (opts: { noServer: boolean }) => TerminalWsServer; +}; +const CUSTOM_IMAGE_TERMINAL_UTF8_ENV = { + LANG: "C.UTF-8", + LC_CTYPE: "C.UTF-8", +}; +const TERMINAL_AUTH_TIMEOUT_MS = 10_000; + +function isWritableUpgradeSocket(socket: Duplex) { + const maybeWritableState = socket as Duplex & { writable?: boolean; writableEnded?: boolean; writableDestroyed?: boolean }; + return !socket.destroyed && maybeWritableState.writable !== false && !maybeWritableState.writableEnded && !maybeWritableState.writableDestroyed; +} + +function closeUpgradeSocket(socket: Duplex) { + if (!socket.destroyed) { + socket.destroy(); + } +} + +function rejectUpgrade(socket: Duplex, statusLine: string, message: string) { + const safe = message.replace(/[\r\n]+/g, " ").trim(); + if (!isWritableUpgradeSocket(socket)) { + closeUpgradeSocket(socket); + return; + } + + try { + socket.once("finish", () => closeUpgradeSocket(socket)); + socket.end(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${safe}`); + } catch (err) { + logger.warn({ errorName: err instanceof Error ? err.name : typeof err }, "failed to reject custom image terminal websocket upgrade"); + closeUpgradeSocket(socket); + } +} + +function parseTerminalPath(pathname: string): { setupSessionId: string } | null { + const match = pathname.match(/^\/api\/environment-custom-image-setup-sessions\/([^/]+)\/terminal\/ws$/); + if (!match) return null; + + try { + const setupSessionId = decodeURIComponent(match[1] ?? ""); + return setupSessionId ? { setupSessionId } : null; + } catch { + return null; + } +} + +function parseTerminalDimension(value: string | null, fallback: number) { + if (!value) return fallback; + if (!/^\d{1,4}$/.test(value)) return fallback; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 9999 ? parsed : fallback; +} + +function safeErrorName(err: unknown) { + return err instanceof Error ? err.name : typeof err; +} + +function errorStatus(err: unknown) { + return typeof err === "object" && err !== null && "status" in err + ? Number((err as { status?: unknown }).status) + : 500; +} + +function clientSafeErrorMessage(err: unknown, fallback: string) { + const status = errorStatus(err); + if ([400, 401, 403, 404, 409, 422].includes(status) && err instanceof Error && err.message) { + return err.message; + } + return fallback; +} + +function safeUpgradePath(rawUrl: string | undefined): string | undefined { + if (!rawUrl) return undefined; + try { + const url = new URL(rawUrl, "http://localhost"); + if (url.searchParams.has("token")) { + url.searchParams.set("token", "[redacted]"); + } + return `${url.pathname}${url.search}`; + } catch { + return rawUrl.split("?")[0] || undefined; + } +} + +function terminalPayloadValidationError( + failure: Extract, +): Error { + return failure.status === 409 ? conflict(failure.message) : unprocessable(failure.message); +} + +function decodeClientMessage(data: unknown): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + return ""; +} + +function sendJson(socket: TerminalWsSocket, frame: Record) { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(frame)); +} + +function closeClient(socket: TerminalWsSocket, code: number, reason: string) { + if (socket.readyState !== WebSocket.OPEN) return; + socket.close(code, reason.slice(0, 120)); +} + +function readResizeDimensions(frame: Record): { cols: number; rows: number } | null { + const cols = typeof frame.cols === "number" && Number.isInteger(frame.cols) ? frame.cols : null; + const rows = typeof frame.rows === "number" && Number.isInteger(frame.rows) ? frame.rows : null; + if ( + cols !== null + && rows !== null + && cols > 0 + && rows > 0 + && cols <= 9999 + && rows <= 9999 + ) { + return { cols, rows }; + } + return null; +} + +function parseJsonClientFrame(raw: unknown): Record | null { + const text = decodeClientMessage(raw); + if (!text) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; +} + +function readAuthTokenFrame(raw: unknown): string | null { + const frame = parseJsonClientFrame(raw); + if (!frame || frame.type !== "auth" || typeof frame.token !== "string") return null; + const token = frame.token.trim(); + return token || null; +} + +function readPreAuthResizeFrame(raw: unknown): { cols: number; rows: number } | null { + const frame = parseJsonClientFrame(raw); + return frame?.type === "resize" ? readResizeDimensions(frame) : null; +} + +function handleClientFrame( + socket: TerminalWsSocket, + shell: EnvironmentCustomImageSshShell | null, + raw: unknown, + onPendingResize?: (dimensions: { cols: number; rows: number }) => void, +) { + const text = decodeClientMessage(raw); + if (!text) return; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + shell?.write(text); + return; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return; + } + const frame = parsed as Record; + if (frame.type === "input") { + if (typeof frame.data === "string") { + shell?.write(frame.data); + } + return; + } + if (frame.type === "resize") { + const dimensions = readResizeDimensions(frame); + if (dimensions) { + if (shell) { + shell.resize(dimensions.cols, dimensions.rows); + } else { + onPendingResize?.(dimensions); + } + } + return; + } + + sendJson(socket, { type: "error", message: "Unsupported terminal frame." }); +} + +async function validateTerminalUpgrade(input: { + setupSessionId: string; + terminalSessionId: string; + token: string; + now: Date; + sessionStore: EnvironmentCustomImageTerminalSessionStore; + customImages: CustomImageTerminalService; +}): Promise { + const terminalSession = input.sessionStore.get({ + id: input.terminalSessionId, + token: input.token, + }, input.now); + if (!terminalSession || terminalSession.setupSessionId !== input.setupSessionId) { + throw unprocessable("Invalid terminal session token."); + } + + const storedSetupSession = await input.customImages.getSessionById(input.setupSessionId); + if (!storedSetupSession) { + input.sessionStore.delete(terminalSession.id); + throw unprocessable("Invalid terminal setup session."); + } + const storedSetupCompanyId = readCustomImageSetupSessionCompanyId(storedSetupSession); + if ( + storedSetupCompanyId !== terminalSession.companyId + || storedSetupSession.environmentId !== terminalSession.environmentId + || storedSetupSession.provider !== terminalSession.provider + ) { + input.sessionStore.delete(terminalSession.id); + throw unprocessable("Invalid terminal setup session."); + } + + const refreshed = await input.customImages.refreshSetupSession({ + sessionId: input.setupSessionId, + includeConnectionPayload: true, + }); + if ( + refreshed.session.id !== terminalSession.setupSessionId + || readCustomImageSetupSessionCompanyId(refreshed.session) !== terminalSession.companyId + || refreshed.session.environmentId !== terminalSession.environmentId + || refreshed.session.provider !== terminalSession.provider + ) { + input.sessionStore.delete(terminalSession.id); + throw unprocessable("Invalid terminal setup session."); + } + if (refreshed.session.status !== "waiting_for_user") { + input.sessionStore.delete(terminalSession.id); + throw conflict(`Cannot open terminal for setup status "${refreshed.session.status}".`); + } + const sessionExpiresAt = requireFutureCustomImageSetupExpiry(refreshed.session, input.now); + + const payloadValidation = validateCustomImageSetupSshPayload(refreshed.connectionPayload, input.now); + if (!payloadValidation.ok) { + input.sessionStore.delete(terminalSession.id); + throw terminalPayloadValidationError(payloadValidation); + } + + return { ...terminalSession, ssh: payloadValidation.ssh, sessionExpiresAt }; +} + +class Ssh2Shell implements EnvironmentCustomImageSshShell { + constructor( + private readonly client: { + end(): void; + destroy?(): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + }, + private readonly stream: { + write(data: string): void; + end(): void; + destroy?(): void; + setWindow?(rows: number, cols: number, height: number, width: number): void; + on(event: "data", listener: (data: Buffer | string) => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + }, + ) {} + + write(data: string): void { + this.stream.write(data); + } + + resize(cols: number, rows: number): void { + this.stream.setWindow?.(rows, cols, 0, 0); + } + + close(): void { + try { + this.stream.end(); + } catch { + this.stream.destroy?.(); + } + try { + this.client.end(); + } catch { + this.client.destroy?.(); + } + } + + onData(listener: (data: string) => void): void { + this.stream.on("data", (data: Buffer | string) => { + listener(typeof data === "string" ? data : data.toString("utf8")); + }); + } + + onClose(listener: () => void): void { + this.stream.on("close", listener); + this.client.on("close", listener); + } + + onError(listener: (err: Error) => void): void { + this.stream.on("error", listener); + this.client.on("error", listener); + } +} + +export function createSsh2EnvironmentCustomImageSshConnector(): EnvironmentCustomImageSshConnector { + return { + connect: async ({ ssh, term, cols, rows, verifyHostKeySha256 }) => { + const { Client } = require("ssh2") as { + Client: new () => { + once(event: "ready", listener: () => void): void; + once(event: "error", listener: (err: Error) => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + connect(config: Record): void; + shell( + window: { term: string; cols: number; rows: number }, + options: { env?: Record }, + callback: (err: Error | undefined, stream: ConstructorParameters[1]) => void, + ): void; + end(): void; + destroy?(): void; + }; + }; + + return await new Promise((resolve, reject) => { + const client = new Client(); + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + try { + client.end(); + } catch { + client.destroy?.(); + } + reject(err); + }; + + client.once("ready", () => { + client.shell({ term, cols, rows }, { env: CUSTOM_IMAGE_TERMINAL_UTF8_ENV }, (err, stream) => { + if (err || !stream) { + fail(err ?? new Error("SSH shell failed to open.")); + return; + } + if (settled) return; + settled = true; + resolve(new Ssh2Shell(client, stream)); + }); + }); + client.once("error", fail); + client.connect({ + host: ssh.host, + port: ssh.port, + // Daytona-style providers put the ephemeral SSH credential in the username + // and accept the "none" auth method; no password or key is expected here. + username: ssh.username, + hostHash: "sha256", + hostVerifier: (hostKeySha256: string) => verifyHostKeySha256(hostKeySha256), + readyTimeout: 20000, + keepaliveInterval: 15000, + keepaliveCountMax: 3, + }); + }); + }, + }; +} + +export function setupEnvironmentCustomImageTerminalWebSocketServer( + server: HttpServer, + db: Db, + opts: { + pluginWorkerManager?: PluginWorkerManager; + customImageService?: CustomImageTerminalService; + sessionStore?: EnvironmentCustomImageTerminalSessionStore; + connectionRegistry?: EnvironmentCustomImageTerminalConnectionRegistry; + sshConnector?: EnvironmentCustomImageSshConnector; + } = {}, +) { + const wss = new WebSocketServer({ noServer: true }); + const customImages = opts.customImageService ?? environmentCustomImageService(db, { + pluginWorkerManager: opts.pluginWorkerManager, + }); + const sessionStore = opts.sessionStore ?? environmentCustomImageTerminalSessionStore; + const connectionRegistry = opts.connectionRegistry ?? environmentCustomImageTerminalConnectionRegistry; + const sshConnector = opts.sshConnector ?? createSsh2EnvironmentCustomImageSshConnector(); + + wss.on("connection", (socket: TerminalWsSocket, req: IncomingMessage) => { + const upgradeContext = (req as IncomingMessageWithTerminalContext).paperclipTerminalUpgradeContext; + if (!upgradeContext) { + socket.close(1008, "missing context"); + return; + } + + let shell: EnvironmentCustomImageSshShell | null = null; + let pendingResize: { cols: number; rows: number } | null = null; + let preAuthResize: { cols: number; rows: number } | null = null; + let cleanupRegistry: (() => void) | null = null; + let authenticatedContext: AuthenticatedTerminalContext | null = null; + let authenticating = false; + let authenticated = false; + let expiryTimer: ReturnType | null = null; + let authTimer: ReturnType | null = null; + let cleanedUp = false; + + const cleanup = (reason: string) => { + if (cleanedUp) return; + cleanedUp = true; + if (authTimer) clearTimeout(authTimer); + if (expiryTimer) clearTimeout(expiryTimer); + cleanupRegistry?.(); + cleanupRegistry = null; + const terminalSessionId = authenticatedContext?.terminalSession.id ?? upgradeContext.terminalSessionId; + if (authenticatedContext) { + sessionStore.delete(authenticatedContext.terminalSession.id); + } + if (shell) { + shell.close(); + shell = null; + } + logger.info({ + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId, + reason, + }, "custom image terminal websocket closed"); + }; + + const closeTerminal = (reason: string, code = 1000, socketReason = "closed") => { + sendJson(socket, { type: "closed", reason }); + closeClient(socket, code, socketReason); + cleanup(reason); + }; + + const startAuthenticatedTerminal = (context: AuthenticatedTerminalContext) => { + authenticatedContext = context; + if (authTimer) { + clearTimeout(authTimer); + authTimer = null; + } + if (preAuthResize) { + pendingResize = preAuthResize; + preAuthResize = null; + } + + cleanupRegistry = connectionRegistry.add({ + setupSessionId: context.setupSessionId, + close: (reason) => { + closeTerminal(reason); + }, + }); + + const expiresInMs = Math.max(0, context.terminalSession.sessionExpiresAt.getTime() - Date.now()); + expiryTimer = setTimeout(() => { + closeTerminal("expired", 1008, "expired"); + }, expiresInMs); + + void sshConnector.connect({ + ssh: context.ssh, + term: "xterm-256color", + cols: context.initialCols, + rows: context.initialRows, + verifyHostKeySha256: (hostKeySha256) => sessionStore.verifyOrPinHostKey({ + id: context.terminalSession.id, + hostKeySha256, + }), + }) + .then((connectedShell) => { + if (cleanedUp) { + connectedShell.close(); + return; + } + shell = connectedShell; + if (pendingResize) { + shell.resize(pendingResize.cols, pendingResize.rows); + pendingResize = null; + } + shell.onData((data) => { + sendJson(socket, { type: "output", data }); + }); + shell.onClose(() => { + if (cleanedUp) return; + closeTerminal("ssh_closed"); + }); + shell.onError((err) => { + if (cleanedUp) return; + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }, "custom image terminal ssh stream failed"); + sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); + closeClient(socket, 1011, "ssh error"); + cleanup("ssh_error"); + }); + sendJson(socket, { + type: "ready", + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }); + }) + .catch((err) => { + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: context.setupSessionId, + terminalSessionId: context.terminalSession.id, + }, "custom image terminal ssh connection failed"); + sendJson(socket, { type: "error", message: "SSH terminal connection failed." }); + closeClient(socket, 1011, "ssh error"); + cleanup("ssh_connect_error"); + }); + }; + + authTimer = setTimeout(() => { + sendJson(socket, { type: "error", message: "Terminal authentication timed out." }); + closeClient(socket, 1008, "terminal auth timeout"); + cleanup("auth_timeout"); + }, TERMINAL_AUTH_TIMEOUT_MS); + + socket.on("message", (data: unknown) => { + if (!authenticated) { + const resize = readPreAuthResizeFrame(data); + if (resize) { + preAuthResize = resize; + return; + } + + const token = readAuthTokenFrame(data); + if (!token) { + sendJson(socket, { type: "error", message: "Terminal authentication is required." }); + closeClient(socket, 1008, "terminal auth required"); + cleanup("auth_frame_invalid"); + return; + } + if (authenticating) return; + authenticating = true; + + void validateTerminalUpgrade({ + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId: upgradeContext.terminalSessionId, + token, + now: new Date(), + sessionStore, + customImages, + }) + .then((terminalSession) => { + authenticating = false; + if (cleanedUp || socket.readyState !== WebSocket.OPEN) { + sessionStore.delete(terminalSession.id); + return; + } + authenticated = true; + startAuthenticatedTerminal({ + setupSessionId: upgradeContext.setupSessionId, + terminalSession, + ssh: terminalSession.ssh, + initialCols: upgradeContext.initialCols, + initialRows: upgradeContext.initialRows, + }); + }) + .catch((err) => { + authenticating = false; + logger.warn({ + errorName: safeErrorName(err), + setupSessionId: upgradeContext.setupSessionId, + terminalSessionId: upgradeContext.terminalSessionId, + }, "custom image terminal websocket authentication rejected"); + sendJson(socket, { + type: "error", + message: clientSafeErrorMessage(err, "Terminal authentication failed."), + }); + closeClient(socket, errorStatus(err) >= 500 ? 1011 : 1008, "terminal auth rejected"); + cleanup("auth_rejected"); + }); + return; + } + + handleClientFrame(socket, shell, data, (dimensions) => { + pendingResize = dimensions; + }); + }); + + socket.on("close", () => { + cleanup("client_closed"); + }); + + socket.on("error", () => { + cleanup("client_error"); + }); + }); + + wss.on("close", () => { + connectionRegistry.closeAll("server_shutdown"); + }); + + if (typeof server.on !== "function") { + return wss; + } + + server.on("close", () => { + connectionRegistry.closeAll("server_shutdown"); + for (const client of wss.clients) { + client.terminate(); + } + wss.close(); + }); + + server.on("upgrade", (req, socket, head) => { + const reqWithContext = req as IncomingMessageWithTerminalContext; + if (!req.url) return; + + const url = new URL(req.url, "http://localhost"); + const path = parseTerminalPath(url.pathname); + if (!path) return; + + reqWithContext.paperclipWebSocketHandled = true; + const logPath = safeUpgradePath(req.url); + + const onRawSocketError = (err: Error) => { + logger.warn({ errorName: safeErrorName(err), path: logPath }, "custom image terminal websocket upgrade socket error"); + }; + const cleanupRawSocketListeners = () => { + socket.off("error", onRawSocketError); + socket.off("close", cleanupRawSocketListeners); + }; + + socket.on("error", onRawSocketError); + socket.once("close", cleanupRawSocketListeners); + + const terminalSessionId = url.searchParams.get("terminalSessionId")?.trim() ?? ""; + const initialCols = parseTerminalDimension(url.searchParams.get("cols"), 80); + const initialRows = parseTerminalDimension(url.searchParams.get("rows"), 24); + if (!terminalSessionId) { + rejectUpgrade(socket, "400 Bad Request", "missing terminal session"); + return; + } + + reqWithContext.paperclipTerminalUpgradeContext = { + setupSessionId: path.setupSessionId, + terminalSessionId, + initialCols, + initialRows, + }; + + cleanupRawSocketListeners(); + wss.handleUpgrade(req, socket, head, (ws: TerminalWsSocket) => { + wss.emit("connection", ws, reqWithContext); + }); + }); + + return wss; +} diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 07f4b05f4f..2dbcd0f5ba 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -47,6 +47,7 @@ interface UpgradeContext { } interface IncomingMessageWithContext extends IncomingMessage { + paperclipWebSocketHandled?: boolean; paperclipUpgradeContext?: UpgradeContext; } @@ -255,6 +256,10 @@ export function setupLiveEventsWebSocketServer( }); server.on("upgrade", (req, socket, head) => { + if ((req as IncomingMessageWithContext).paperclipWebSocketHandled) { + return; + } + const onRawSocketError = (err: Error) => { logger.warn({ err, path: req.url }, "live websocket upgrade socket error"); }; diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 2ac76b9393..8cbe724c4e 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { AGENT_ADAPTER_TYPES, cancelEnvironmentCustomImageSetupSessionSchema, + createEnvironmentCustomImageTerminalSessionTokenSchema, createEnvironmentSchema, finishEnvironmentCustomImageSetupSessionSchema, getEnvironmentCapabilities, @@ -21,6 +22,16 @@ import { logActivity, projectService, } from "../services/index.js"; +import { + environmentCustomImageTerminalConnectionRegistry, + environmentCustomImageTerminalSessionStore, + validateCustomImageSetupSshPayload, + type EnvironmentCustomImageTerminalPayloadValidationResult, +} from "../services/environment-custom-image-terminal-sessions.js"; +import { + readCustomImageSetupSessionCompanyId, + requireFutureCustomImageSetupExpiry, +} from "../services/environment-custom-image-setup-session-utils.js"; import { collectEnvironmentSecretRefs, normalizeEnvironmentConfigForPersistence, @@ -71,6 +82,17 @@ export function environmentRoutes( assertBoardOrgAccess(req); } + function assertCustomImageCompanyAccess(req: Request, companyId: string) { + if (req.actor.type !== "board") { + throw forbidden("Board access required"); + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowedCompanies = req.actor.companyIds ?? []; + if (!allowedCompanies.includes(companyId)) { + throw forbidden("User does not have access to this company"); + } + } + function canReadFullInstanceEnvironment(req: Request) { return req.actor.type === "board" && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin); @@ -128,6 +150,59 @@ export function environmentRoutes( ); } + async function logEnvironmentCustomImageActivity(input: { + actor: ReturnType; + companyId: string; + action: string; + entityId: string; + details: Record; + }) { + await logActivity(db, { + companyId: input.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: input.action, + entityType: "environment", + entityId: input.entityId, + details: input.details, + }); + } + + async function resolveCustomImageCompanyId(req: Request): Promise { + const queryCompanyId = + typeof req.query.companyId === "string" && req.query.companyId.trim().length > 0 + ? req.query.companyId.trim() + : null; + if (queryCompanyId) { + assertCustomImageCompanyAccess(req, queryCompanyId); + return queryCompanyId; + } + if (req.actor.type === "board" && req.actor.companyIds?.length === 1) { + return req.actor.companyIds[0]!; + } + const companyIds = await instanceSettings.listCompanyIds(); + if (companyIds.length === 1 && companyIds[0]) { + const companyId = companyIds[0]; + assertCustomImageCompanyAccess(req, companyId); + return companyId; + } + throw unprocessable("companyId query parameter is required for environment customImage setup."); + } + + async function resolveCustomImageSessionCompanyId( + req: Request, + session: { metadata?: Record | null }, + ): Promise { + const metadataCompanyId = readCustomImageSetupSessionCompanyId(session); + if (metadataCompanyId) { + assertCustomImageCompanyAccess(req, metadataCompanyId); + return metadataCompanyId; + } + return await resolveCustomImageCompanyId(req); + } + async function resolveEnvironmentSecretContextCompanyId( req: Request, environmentId: string, @@ -241,6 +316,15 @@ export function environmentRoutes( }); } + function throwTerminalPayloadValidationFailure( + failure: Extract, + ): never { + if (failure.status === 409) { + throw conflict(failure.message); + } + throw unprocessable(failure.message); + } + router.get("/companies/:companyId/environments", async (req, res) => { assertCanReadInstanceEnvironments(req); const rows = await svc.list({ @@ -287,6 +371,7 @@ export function environmentRoutes( router.get("/environments/:environmentId/custom-image-template", async (req, res) => { assertCanAccessInstanceEnvironments(req); + await resolveCustomImageCompanyId(req); const overview = await customImages.getOverview({ environmentId: req.params.environmentId as string, }); @@ -298,19 +383,21 @@ export function environmentRoutes( validate(startEnvironmentCustomImageSetupSessionSchema), async (req, res) => { assertCanAccessInstanceEnvironments(req); - const environmentId = req.params.environmentId as string; + const companyId = await resolveCustomImageCompanyId(req); const actor = getActorInfo(req); const result = await customImages.startSetupSession({ - environmentId, + environmentId: req.params.environmentId as string, templateId: req.body.templateId ?? null, ttlSeconds: req.body.ttlSeconds ?? null, actor: { userId: actor.actorType === "user" ? actor.actorId : null, agentId: actor.agentId, }, + secretContextCompanyId: companyId, }); - await logInstanceEnvironmentActivity({ + await logEnvironmentCustomImageActivity({ actor, + companyId, action: "environment.custom_image_setup.started", entityId: result.session.environmentId, details: setupSessionActivityDetails(result.session), @@ -326,6 +413,7 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } + await resolveCustomImageSessionCompanyId(req, session); const result = await customImages.refreshSetupSession({ sessionId: session.id, includeConnectionPayload: true, @@ -333,6 +421,71 @@ export function environmentRoutes( res.json(result); }); + router.post( + "/environment-custom-image-setup-sessions/:sessionId/terminal-session-token", + validate(createEnvironmentCustomImageTerminalSessionTokenSchema), + async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const session = await customImages.getSessionById(req.params.sessionId as string); + if (!session) { + res.status(404).json({ error: "Environment customImage setup session not found" }); + return; + } + const companyId = await resolveCustomImageSessionCompanyId(req, session); + + const refreshed = await customImages.refreshSetupSession({ + sessionId: session.id, + includeConnectionPayload: true, + }); + const now = new Date(); + if (refreshed.session.status !== "waiting_for_user") { + throw conflict(`Cannot create terminal session token from setup status "${refreshed.session.status}".`); + } + const setupExpiresAt = requireFutureCustomImageSetupExpiry(refreshed.session, now); + const payloadValidation = validateCustomImageSetupSshPayload(refreshed.connectionPayload, now); + if (!payloadValidation.ok) { + throwTerminalPayloadValidationFailure(payloadValidation); + } + + const minted = environmentCustomImageTerminalSessionStore.create({ + setupSessionId: refreshed.session.id, + companyId, + environmentId: refreshed.session.environmentId, + provider: refreshed.session.provider, + ssh: payloadValidation.ssh, + setupExpiresAt, + connectionExpiresAt: payloadValidation.connectionExpiresAt, + now, + }); + const actor = getActorInfo(req); + await logEnvironmentCustomImageActivity({ + actor, + companyId, + action: "environment.custom_image_terminal_session_token.created", + entityId: refreshed.session.environmentId, + details: { + session: setupSessionActivityDetails(refreshed.session), + terminalSession: { + connectionType: "ssh", + connectExpiresAt: minted.session.connectExpiresAt.toISOString(), + sessionExpiresAt: minted.session.sessionExpiresAt.toISOString(), + }, + }, + }); + res.status(201).json({ + id: minted.session.id, + token: minted.token, + expiresAt: minted.session.connectExpiresAt.toISOString(), + setupSessionId: minted.session.setupSessionId, + environmentId: minted.session.environmentId, + connectionType: "ssh", + websocketPath: + `/api/environment-custom-image-setup-sessions/${encodeURIComponent(minted.session.setupSessionId)}/terminal/ws` + + `?terminalSessionId=${encodeURIComponent(minted.session.id)}`, + }); + }, + ); + router.post( "/environment-custom-image-setup-sessions/:sessionId/finish", validate(finishEnvironmentCustomImageSetupSessionSchema), @@ -343,13 +496,17 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } + const companyId = await resolveCustomImageSessionCompanyId(req, session); const actor = getActorInfo(req); const result = await customImages.finishSetupSession({ sessionId: session.id, metadata: req.body.metadata, }); - await logInstanceEnvironmentActivity({ + environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id); + environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_finished"); + await logEnvironmentCustomImageActivity({ actor, + companyId, action: "environment.custom_image_setup.finished", entityId: result.session.environmentId, details: { @@ -371,13 +528,17 @@ export function environmentRoutes( res.status(404).json({ error: "Environment customImage setup session not found" }); return; } + const companyId = await resolveCustomImageSessionCompanyId(req, session); const actor = getActorInfo(req); const cancelled = await customImages.cancelSetupSession({ sessionId: session.id, reason: req.body.reason ?? null, }); - await logInstanceEnvironmentActivity({ + environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id); + environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_cancelled"); + await logEnvironmentCustomImageActivity({ actor, + companyId, action: "environment.custom_image_setup.cancelled", entityId: cancelled.environmentId, details: setupSessionActivityDetails(cancelled), @@ -388,12 +549,14 @@ export function environmentRoutes( router.post("/environments/:environmentId/custom-image-template/rollback", async (req, res) => { assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); const actor = getActorInfo(req); const result = await customImages.rollbackTemplate({ environmentId: req.params.environmentId as string, }); - await logInstanceEnvironmentActivity({ + await logEnvironmentCustomImageActivity({ actor, + companyId, action: "environment.custom_image_template.rolled_back", entityId: req.params.environmentId as string, details: { @@ -406,13 +569,15 @@ export function environmentRoutes( router.delete("/environments/:environmentId/custom-image-template", async (req, res) => { assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); const actor = getActorInfo(req); const template = await customImages.disableTemplate({ environmentId: req.params.environmentId as string, deleteProviderTemplate: req.query.deleteProviderTemplate === "true", }); - await logInstanceEnvironmentActivity({ + await logEnvironmentCustomImageActivity({ actor, + companyId, action: "environment.custom_image_template.disabled", entityId: req.params.environmentId as string, details: templateActivityDetails(template), diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 384aa16f2f..2378afa154 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -73,7 +73,9 @@ import { // Environments createEnvironmentSchema, cancelEnvironmentCustomImageSetupSessionSchema, + createEnvironmentCustomImageTerminalSessionTokenSchema, environmentCustomImageSetupSessionSchema, + environmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTemplateSchema, finishEnvironmentCustomImageSetupSessionSchema, updateEnvironmentSchema, @@ -3639,6 +3641,26 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "post", + path: "/api/environment-custom-image-setup-sessions/{sessionId}/terminal-session-token", + tags: ["environments"], + summary: "Mint a short-lived terminal websocket token for a customImage SSH setup session", + request: { + params: z.object({ sessionId: z.string() }), + body: jsonBody(createEnvironmentCustomImageTerminalSessionTokenSchema), + }, + responses: { + 201: r.ok(environmentCustomImageTerminalSessionTokenSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 422: r.unprocessable, + }, +}); + registry.registerPath({ method: "post", path: "/api/environment-custom-image-setup-sessions/{sessionId}/finish", diff --git a/server/src/services/environment-custom-image-setup-session-utils.ts b/server/src/services/environment-custom-image-setup-session-utils.ts new file mode 100644 index 0000000000..828468cb7b --- /dev/null +++ b/server/src/services/environment-custom-image-setup-session-utils.ts @@ -0,0 +1,32 @@ +import { conflict } from "../errors.js"; + +export function readCustomImageSetupSessionCompanyId(session: { + metadata?: Record | null; +}): string | null { + const value = session.metadata?.setupRpcCompanyId; + if (typeof value !== "string") return null; + const companyId = value.trim(); + return companyId && companyId !== "instance" ? companyId : null; +} + +export function readNullableDate(value: unknown): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : null; + return date && !Number.isNaN(date.getTime()) ? date : null; +} + +export function readFutureDate(value: Date | string | null | undefined, now: Date): Date | null { + const date = readNullableDate(value); + return date && date.getTime() > now.getTime() ? date : null; +} + +export function requireFutureCustomImageSetupExpiry( + session: { expiresAt: Date | string | null }, + now: Date, +): Date { + const expiresAt = readFutureDate(session.expiresAt, now); + if (!expiresAt) { + throw conflict("Environment customImage setup session has expired."); + } + return expiresAt; +} diff --git a/server/src/services/environment-custom-image-terminal-sessions.test.ts b/server/src/services/environment-custom-image-terminal-sessions.test.ts new file mode 100644 index 0000000000..a9a6616d18 --- /dev/null +++ b/server/src/services/environment-custom-image-terminal-sessions.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { + EnvironmentCustomImageTerminalConnectionRegistry, + EnvironmentCustomImageTerminalSessionStore, + parseCustomImageSetupSshCommand, + validateCustomImageSetupSshPayload, +} from "./environment-custom-image-terminal-sessions.js"; + +describe("parseCustomImageSetupSshCommand", () => { + it("parses supported SSH command shapes", () => { + expect(parseCustomImageSetupSshCommand("ssh user@example.test")).toEqual({ + username: "user", + host: "example.test", + port: 22, + }); + expect(parseCustomImageSetupSshCommand("ssh user@example.test -p 2222")).toEqual({ + username: "user", + host: "example.test", + port: 2222, + }); + expect(parseCustomImageSetupSshCommand("ssh -p 2200 user@example.test")).toEqual({ + username: "user", + host: "example.test", + port: 2200, + }); + }); + + it("parses the redacted Daytona createSshAccess command shape", () => { + expect(parseCustomImageSetupSshCommand("ssh dtca_redacted-token.123@ssh.app.daytona.io")).toEqual({ + username: "dtca_redacted-token.123", + host: "ssh.app.daytona.io", + port: 22, + }); + }); + + it("rejects unsupported or ambiguous SSH command shapes", () => { + expect(parseCustomImageSetupSshCommand("scp user@example.test")).toBeNull(); + expect(parseCustomImageSetupSshCommand("ssh user@example.test -i key")).toBeNull(); + expect(parseCustomImageSetupSshCommand("ssh user@example.test:2222")).toBeNull(); + expect(parseCustomImageSetupSshCommand("ssh -p not-a-port user@example.test")).toBeNull(); + expect(parseCustomImageSetupSshCommand("ssh -p 70000 user@example.test")).toBeNull(); + expect(parseCustomImageSetupSshCommand("ssh user@@example.test")).toBeNull(); + }); +}); + +describe("validateCustomImageSetupSshPayload", () => { + it("returns parsed SSH connection details and a valid payload expiry", () => { + const result = validateCustomImageSetupSshPayload({ + type: "ssh", + command: "ssh dtca_redacted-token.123@ssh.app.daytona.io", + expiresAt: "2026-06-25T20:15:00.000Z", + }, new Date("2026-06-25T20:00:00.000Z")); + + expect(result).toEqual({ + ok: true, + ssh: { + username: "dtca_redacted-token.123", + host: "ssh.app.daytona.io", + port: 22, + }, + connectionExpiresAt: new Date("2026-06-25T20:15:00.000Z"), + }); + }); + + it("returns redacted fallback failures for unsupported payloads and parser failures", () => { + expect(validateCustomImageSetupSshPayload({ + type: "browser_terminal", + command: "ssh secret-token@203.0.113.10", + }, new Date("2026-06-25T20:00:00.000Z"))).toMatchObject({ + ok: false, + status: 422, + code: "unsupported_payload", + message: "Setup session terminal connections require an SSH connection payload.", + }); + expect(validateCustomImageSetupSshPayload({ + type: "ssh", + command: "ssh secret-token@203.0.113.10 -i /tmp/private-key", + }, new Date("2026-06-25T20:00:00.000Z"))).toMatchObject({ + ok: false, + status: 422, + code: "unsupported_command", + message: "Setup session SSH payload uses an unsupported command shape.", + }); + }); + + it("returns clear failures for invalid and expired payload expiries", () => { + expect(validateCustomImageSetupSshPayload({ + type: "ssh", + command: "ssh token@example.test", + expiresAt: "not-a-date", + }, new Date("2026-06-25T20:00:00.000Z"))).toMatchObject({ + ok: false, + status: 422, + code: "invalid_expiry", + message: "Setup session SSH payload has an invalid expiry.", + }); + expect(validateCustomImageSetupSshPayload({ + type: "ssh", + command: "ssh token@example.test", + expiresAt: "2026-06-25T19:59:59.000Z", + }, new Date("2026-06-25T20:00:00.000Z"))).toMatchObject({ + ok: false, + status: 409, + code: "expired_payload", + message: "Setup session SSH connection payload has expired.", + }); + }); +}); + +describe("EnvironmentCustomImageTerminalSessionStore", () => { + it("mints opaque connect tokens and tracks live session expiry separately", () => { + const store = new EnvironmentCustomImageTerminalSessionStore(); + const now = new Date("2026-06-25T20:00:00.000Z"); + const minted = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "ssh-token-secret", host: "203.0.113.10", port: 2222 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + connectionExpiresAt: new Date("2026-06-25T20:10:00.000Z"), + now, + }); + + expect(minted.token).toHaveLength(43); + expect(minted.session.id).toMatch(/^[0-9a-f-]{36}$/); + expect(minted.session.connectExpiresAt.toISOString()).toBe("2026-06-25T20:05:00.000Z"); + expect(minted.session.sessionExpiresAt.toISOString()).toBe("2026-06-25T20:30:00.000Z"); + expect(minted.session.hostKeySha256).toBeNull(); + expect(store.get({ + id: minted.session.id, + token: minted.token, + }, new Date("2026-06-25T20:01:59.000Z"))?.ssh).toEqual({ + username: "ssh-token-secret", + host: "203.0.113.10", + port: 2222, + }); + expect(store.get({ + id: minted.session.id, + token: "wrong-token", + }, new Date("2026-06-25T20:01:59.000Z"))).toBeNull(); + expect(store.getById(minted.session.id, new Date("2026-06-25T20:05:00.000Z"))?.id) + .toBe(minted.session.id); + expect(store.cleanupExpired(new Date("2026-06-25T20:05:00.000Z"))).toBe(0); + expect(store.get({ + id: minted.session.id, + token: minted.token, + }, new Date("2026-06-25T20:05:00.000Z"))).toBeNull(); + }); + + it("retains post-connection session records until setup-session expiry", () => { + const store = new EnvironmentCustomImageTerminalSessionStore(); + const now = new Date("2026-06-25T20:00:00.000Z"); + const minted = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "ssh-token-secret", host: "203.0.113.10", port: 2222 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + connectionExpiresAt: new Date("2026-06-25T20:01:00.000Z"), + now, + }); + + expect(store.getById(minted.session.id, new Date("2026-06-25T20:05:00.000Z"))?.id) + .toBe(minted.session.id); + expect(store.cleanupExpired(new Date("2026-06-25T20:05:00.000Z"))).toBe(0); + expect(store.getById(minted.session.id, new Date("2026-06-25T20:05:00.000Z"))?.id) + .toBe(minted.session.id); + + expect(store.cleanupExpired(new Date("2026-06-25T20:30:00.000Z"))).toBe(1); + expect(store.getById(minted.session.id, new Date("2026-06-25T20:30:00.000Z"))).toBeNull(); + }); + + it("pins the first SSH host key fingerprint for a terminal session", () => { + const store = new EnvironmentCustomImageTerminalSessionStore(); + const now = new Date("2026-06-25T20:00:00.000Z"); + const minted = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "ssh-token-secret", host: "203.0.113.10", port: 2222 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + now, + }); + + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, now)).toBe(true); + expect(store.getById(minted.session.id, now)?.hostKeySha256).toBe("first-host-key-sha256"); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, now)).toBe(true); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "changed-host-key-sha256", + }, now)).toBe(false); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "", + }, now)).toBe(false); + expect(store.verifyOrPinHostKey({ + id: minted.session.id, + hostKeySha256: "first-host-key-sha256", + }, new Date("2026-06-25T20:30:00.000Z"))).toBe(false); + }); + + it("deletes all tokens for a setup session", () => { + const store = new EnvironmentCustomImageTerminalSessionStore(); + const now = new Date("2026-06-25T20:00:00.000Z"); + const first = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "one", host: "example.test", port: 22 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + now, + }); + const second = store.create({ + setupSessionId: "session-1", + companyId: "company-1", + environmentId: "env-1", + provider: "daytona", + ssh: { username: "two", host: "example.test", port: 22 }, + setupExpiresAt: new Date("2026-06-25T20:30:00.000Z"), + now, + }); + + expect(store.deleteBySetupSessionId("session-1")).toBe(2); + expect(store.get({ id: first.session.id, token: first.token }, now)).toBeNull(); + expect(store.get({ id: second.session.id, token: second.token }, now)).toBeNull(); + }); +}); + +describe("EnvironmentCustomImageTerminalConnectionRegistry", () => { + it("closes active terminal connections for a setup session", () => { + const registry = new EnvironmentCustomImageTerminalConnectionRegistry(); + const firstReasons: string[] = []; + const secondReasons: string[] = []; + const removeFirst = registry.add({ + setupSessionId: "session-1", + close: (reason) => firstReasons.push(reason), + }); + registry.add({ + setupSessionId: "session-1", + close: (reason) => secondReasons.push(reason), + }); + + expect(registry.closeBySetupSessionId("session-1", "setup_finished")).toBe(2); + expect(firstReasons).toEqual(["setup_finished"]); + expect(secondReasons).toEqual(["setup_finished"]); + + removeFirst(); + expect(registry.closeBySetupSessionId("session-1", "setup_cancelled")).toBe(1); + expect(firstReasons).toEqual(["setup_finished"]); + expect(secondReasons).toEqual(["setup_finished", "setup_cancelled"]); + }); +}); diff --git a/server/src/services/environment-custom-image-terminal-sessions.ts b/server/src/services/environment-custom-image-terminal-sessions.ts new file mode 100644 index 0000000000..fce11f2761 --- /dev/null +++ b/server/src/services/environment-custom-image-terminal-sessions.ts @@ -0,0 +1,353 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { + readFutureDate, + readNullableDate, +} from "./environment-custom-image-setup-session-utils.js"; + +const DEFAULT_TERMINAL_SESSION_TOKEN_TTL_MS = 5 * 60 * 1000; +const TERMINAL_SESSION_TOKEN_BYTES = 32; + +export interface ParsedCustomImageSetupSshCommand { + username: string; + host: string; + port: number; +} + +export type EnvironmentCustomImageTerminalPayloadValidationFailureCode = + | "unsupported_payload" + | "missing_command" + | "unsupported_command" + | "invalid_expiry" + | "expired_payload"; + +export type EnvironmentCustomImageTerminalPayloadValidationResult = + | { + ok: true; + ssh: ParsedCustomImageSetupSshCommand; + connectionExpiresAt: Date | null; + } + | { + ok: false; + status: 409 | 422; + code: EnvironmentCustomImageTerminalPayloadValidationFailureCode; + message: string; + }; + +export interface EnvironmentCustomImageTerminalSessionRecord { + id: string; + setupSessionId: string; + companyId: string; + environmentId: string; + provider: string; + connectionType: "ssh"; + ssh: ParsedCustomImageSetupSshCommand; + hostKeySha256: string | null; + createdAt: Date; + connectExpiresAt: Date; + sessionExpiresAt: Date; +} + +export interface MintedEnvironmentCustomImageTerminalSession { + token: string; + session: EnvironmentCustomImageTerminalSessionRecord; +} + +interface StoredEnvironmentCustomImageTerminalSession { + tokenHash: string; + session: EnvironmentCustomImageTerminalSessionRecord; +} + +function parsePort(value: string): number | null { + if (!/^\d{1,5}$/.test(value)) return null; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null; +} + +function parseDestination(value: string): Pick | null { + if (value.startsWith("-")) return null; + const parts = value.split("@"); + if (parts.length !== 2) return null; + const [username, host] = parts; + if (!username || !host) return null; + if (!/^[^\s@/]+$/.test(username)) return null; + if (!/^[^\s@/:]+$/.test(host)) return null; + return { username, host }; +} + +export function parseCustomImageSetupSshCommand(command: string): ParsedCustomImageSetupSshCommand | null { + const tokens = command.trim().split(/\s+/).filter(Boolean); + if (tokens[0] !== "ssh") return null; + + if (tokens.length === 2) { + const destination = parseDestination(tokens[1]!); + return destination ? { ...destination, port: 22 } : null; + } + + if (tokens.length !== 4) return null; + + if (tokens[1] === "-p") { + const port = parsePort(tokens[2]!); + const destination = parseDestination(tokens[3]!); + return port && destination ? { ...destination, port } : null; + } + + if (tokens[2] === "-p") { + const destination = parseDestination(tokens[1]!); + const port = parsePort(tokens[3]!); + return port && destination ? { ...destination, port } : null; + } + + return null; +} + +function readConnectionPayload(payload: unknown): Record | null { + return payload && typeof payload === "object" && !Array.isArray(payload) + ? payload as Record + : null; +} + +export function validateCustomImageSetupSshPayload( + payload: unknown, + now: Date, +): EnvironmentCustomImageTerminalPayloadValidationResult { + const record = readConnectionPayload(payload); + if (!record || record.type !== "ssh") { + return { + ok: false, + status: 422, + code: "unsupported_payload", + message: "Setup session terminal connections require an SSH connection payload.", + }; + } + + const command = typeof record.command === "string" ? record.command.trim() : ""; + if (!command) { + return { + ok: false, + status: 422, + code: "missing_command", + message: "Setup session SSH payload is missing a supported command.", + }; + } + + const ssh = parseCustomImageSetupSshCommand(command); + if (!ssh) { + return { + ok: false, + status: 422, + code: "unsupported_command", + message: "Setup session SSH payload uses an unsupported command shape.", + }; + } + + const connectionExpiresAt = readNullableDate(record.expiresAt); + if (record.expiresAt != null && !connectionExpiresAt) { + return { + ok: false, + status: 422, + code: "invalid_expiry", + message: "Setup session SSH payload has an invalid expiry.", + }; + } + if (connectionExpiresAt && connectionExpiresAt.getTime() <= now.getTime()) { + return { + ok: false, + status: 409, + code: "expired_payload", + message: "Setup session SSH connection payload has expired.", + }; + } + + return { ok: true, ssh, connectionExpiresAt }; +} + +function hashTerminalSessionToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function minDate(dates: Date[]): Date { + return new Date(Math.min(...dates.map((date) => date.getTime()))); +} + +function toValidFutureDate(value: Date | string | null | undefined, now: Date): Date | null { + return readFutureDate(value, now); +} + +function normalizeHostKeySha256(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized && normalized.length <= 256 ? normalized : null; +} + +export class EnvironmentCustomImageTerminalSessionStore { + private readonly sessionsById = new Map(); + + create(input: { + setupSessionId: string; + companyId: string; + environmentId: string; + provider: string; + ssh: ParsedCustomImageSetupSshCommand; + setupExpiresAt: Date | string; + connectionExpiresAt?: Date | string | null; + now?: Date; + }): MintedEnvironmentCustomImageTerminalSession { + const now = input.now ?? new Date(); + this.cleanupExpired(now); + + const setupExpiresAt = toValidFutureDate(input.setupExpiresAt, now); + if (!setupExpiresAt) { + throw new Error("Terminal sessions require a future setup session expiry."); + } + const candidateExpirations = [ + new Date(now.getTime() + DEFAULT_TERMINAL_SESSION_TOKEN_TTL_MS), + setupExpiresAt, + toValidFutureDate(input.connectionExpiresAt, now), + ].filter((date): date is Date => date !== null); + const connectExpiresAt = minDate(candidateExpirations); + const token = randomBytes(TERMINAL_SESSION_TOKEN_BYTES).toString("base64url"); + const id = randomUUID(); + const session: EnvironmentCustomImageTerminalSessionRecord = { + id, + setupSessionId: input.setupSessionId, + companyId: input.companyId, + environmentId: input.environmentId, + provider: input.provider, + connectionType: "ssh", + ssh: input.ssh, + hostKeySha256: null, + createdAt: now, + connectExpiresAt, + sessionExpiresAt: setupExpiresAt, + }; + this.sessionsById.set(id, { + tokenHash: hashTerminalSessionToken(token), + session, + }); + return { token, session }; + } + + get(input: { id: string; token: string }, now = new Date()): EnvironmentCustomImageTerminalSessionRecord | null { + if (!input.id || !input.token) return null; + const stored = this.sessionsById.get(input.id) ?? null; + if (!stored) return null; + if (stored.tokenHash !== hashTerminalSessionToken(input.token)) return null; + if (stored.session.connectExpiresAt.getTime() <= now.getTime()) { + this.sessionsById.delete(input.id); + return null; + } + return stored.session; + } + + getById(id: string, now = new Date()): EnvironmentCustomImageTerminalSessionRecord | null { + if (!id) return null; + const stored = this.sessionsById.get(id) ?? null; + if (!stored) return null; + if (stored.session.sessionExpiresAt.getTime() <= now.getTime()) { + this.sessionsById.delete(id); + return null; + } + return stored.session; + } + + verifyOrPinHostKey(input: { id: string; hostKeySha256: string }, now = new Date()): boolean { + const hostKeySha256 = normalizeHostKeySha256(input.hostKeySha256); + if (!input.id || !hostKeySha256) return false; + const stored = this.sessionsById.get(input.id) ?? null; + if (!stored) return false; + if (stored.session.sessionExpiresAt.getTime() <= now.getTime()) { + this.sessionsById.delete(input.id); + return false; + } + if (!stored.session.hostKeySha256) { + stored.session.hostKeySha256 = hostKeySha256; + return true; + } + return stored.session.hostKeySha256 === hostKeySha256; + } + + delete(id: string): boolean { + if (!id) return false; + return this.sessionsById.delete(id); + } + + deleteBySetupSessionId(setupSessionId: string): number { + if (!setupSessionId) return 0; + let removed = 0; + for (const [id, stored] of this.sessionsById) { + if (stored.session.setupSessionId !== setupSessionId) continue; + this.sessionsById.delete(id); + removed += 1; + } + return removed; + } + + cleanupExpired(now = new Date()): number { + let removed = 0; + for (const [id, stored] of this.sessionsById) { + if (stored.session.sessionExpiresAt.getTime() <= now.getTime()) { + this.sessionsById.delete(id); + removed += 1; + } + } + return removed; + } + + clear(): void { + this.sessionsById.clear(); + } +} + +export const environmentCustomImageTerminalSessionStore = + new EnvironmentCustomImageTerminalSessionStore(); + +export type EnvironmentCustomImageTerminalConnectionClose = (reason: string) => void; + +export class EnvironmentCustomImageTerminalConnectionRegistry { + private readonly connectionsBySetupSessionId = new Map>(); + + add(input: { + setupSessionId: string; + close: EnvironmentCustomImageTerminalConnectionClose; + }): () => void { + const existing = this.connectionsBySetupSessionId.get(input.setupSessionId); + const connections = existing ?? new Set(); + connections.add(input.close); + if (!existing) { + this.connectionsBySetupSessionId.set(input.setupSessionId, connections); + } + + return () => { + connections.delete(input.close); + if (connections.size === 0) { + this.connectionsBySetupSessionId.delete(input.setupSessionId); + } + }; + } + + closeBySetupSessionId(setupSessionId: string, reason: string): number { + const connections = this.connectionsBySetupSessionId.get(setupSessionId); + if (!connections) return 0; + let closed = 0; + for (const close of [...connections]) { + close(reason); + closed += 1; + } + return closed; + } + + closeAll(reason: string): number { + let closed = 0; + for (const setupSessionId of [...this.connectionsBySetupSessionId.keys()]) { + closed += this.closeBySetupSessionId(setupSessionId, reason); + } + return closed; + } + + clear(): void { + this.connectionsBySetupSessionId.clear(); + } +} + +export const environmentCustomImageTerminalConnectionRegistry = + new EnvironmentCustomImageTerminalConnectionRegistry(); diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index ae3b2f0c6a..1cef716cf5 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -120,7 +120,7 @@ function normalizeConnectionSummary( const label = readString((summary as unknown as Record).label); return { type: readConnectionType(summary.type), - username: readString(summary.username), + username: null, hostRedacted: true, portRedacted: true, ...(label ? { label } : {}), diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 855d3a2652..13cdaf711d 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -98,6 +98,17 @@ export { export { environmentCustomImageService, } from "./environment-custom-images.js"; +export { + environmentCustomImageTerminalConnectionRegistry, + environmentCustomImageTerminalSessionStore, + EnvironmentCustomImageTerminalConnectionRegistry, + EnvironmentCustomImageTerminalSessionStore, + parseCustomImageSetupSshCommand, + type EnvironmentCustomImageTerminalConnectionClose, + type EnvironmentCustomImageTerminalSessionRecord, + type MintedEnvironmentCustomImageTerminalSession, + type ParsedCustomImageSetupSshCommand, +} from "./environment-custom-image-terminal-sessions.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; export { workspaceOperationService } from "./workspace-operations.js"; export { workspaceFileResourceService } from "./workspace-file-resources.js"; diff --git a/ui/package.json b/ui/package.json index 0e144db7f3..9665b0012a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -50,6 +50,8 @@ "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.90.21", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 7cfe039cb9..4740427eb4 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -6,8 +6,10 @@ import type { EnvironmentProbeResult, EnvironmentCustomImageSetupSession, EnvironmentCustomImageTemplate, + EnvironmentCustomImageTerminalSessionToken, FinishEnvironmentCustomImageSetupSession, StartEnvironmentCustomImageSetupSession, + CreateEnvironmentCustomImageTerminalSessionToken, } from "@paperclipai/shared"; import { api } from "./client"; @@ -81,6 +83,14 @@ export const environmentsApi = { api.get( `/environment-custom-image-setup-sessions/${sessionId}`, ), + createCustomImageTerminalSessionToken: ( + sessionId: string, + body: CreateEnvironmentCustomImageTerminalSessionToken = {}, + ) => + api.post( + `/environment-custom-image-setup-sessions/${sessionId}/terminal-session-token`, + body, + ), finishCustomImageSetupSession: ( sessionId: string, body: FinishEnvironmentCustomImageSetupSession = {}, diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index de4f28833d..7003642314 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -6,6 +6,120 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CompanyEnvironments } from "./CompanyEnvironments"; +const xtermMocks = vi.hoisted(() => { + class MockTerminal { + readonly options: Record; + cols: number; + rows: number; + writes: string[] = []; + focused = false; + disposed = false; + openedElement: HTMLElement | null = null; + private readonly dataHandlers: Array<(data: string) => void> = []; + + constructor(options: Record = {}) { + this.options = options; + this.cols = typeof options.cols === "number" ? options.cols : 80; + this.rows = typeof options.rows === "number" ? options.rows : 24; + xtermMocks.terminalInstances.push(this); + } + + loadAddon(addon: { activate?: (terminal: MockTerminal) => void }) { + addon.activate?.(this); + } + + open(element: HTMLElement) { + this.openedElement = element; + element.dataset.mockXtermOpen = "true"; + } + + onData(handler: (data: string) => void) { + this.dataHandlers.push(handler); + return { + dispose: () => { + const index = this.dataHandlers.indexOf(handler); + if (index >= 0) this.dataHandlers.splice(index, 1); + }, + }; + } + + emitData(data: string) { + for (const handler of this.dataHandlers) handler(data); + } + + write(data: string) { + this.writes.push(data); + if (!this.openedElement) return; + const chunk = document.createElement("span"); + // Keep test DOM readable without reimplementing xterm's ANSI parser. + chunk.textContent = data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); + this.openedElement.appendChild(chunk); + } + + resize(cols: number, rows: number) { + this.cols = cols; + this.rows = rows; + } + + focus() { + this.focused = true; + this.openedElement?.focus(); + } + + reset() { + this.writes = []; + if (this.openedElement) this.openedElement.textContent = ""; + } + + clear() { + if (this.openedElement) this.openedElement.textContent = ""; + } + + dispose() { + this.disposed = true; + } + } + + class MockFitAddon { + fitCalls = 0; + terminal: MockTerminal | null = null; + + constructor() { + xtermMocks.fitAddonInstances.push(this); + } + + activate(terminal: MockTerminal) { + this.terminal = terminal; + } + + fit() { + this.fitCalls += 1; + this.terminal?.resize(120, 32); + } + } + + return { + MockTerminal, + MockFitAddon, + terminalInstances: [] as MockTerminal[], + fitAddonInstances: [] as MockFitAddon[], + reset() { + this.terminalInstances.length = 0; + this.fitAddonInstances.length = 0; + }, + }; +}); + +vi.mock("@xterm/xterm", () => ({ + Terminal: xtermMocks.MockTerminal, +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: xtermMocks.MockFitAddon, +})); + +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn(), @@ -18,6 +132,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ customImageTemplate: vi.fn(), startCustomImageSetupSession: vi.fn(), customImageSetupSession: vi.fn(), + createCustomImageTerminalSessionToken: vi.fn(), finishCustomImageSetupSession: vi.fn(), cancelCustomImageSetupSession: vi.fn(), rollbackCustomImageTemplate: vi.fn(), @@ -70,6 +185,45 @@ vi.mock("@/api/secrets", () => ({ disconnect() {} }; +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + + readonly url: string; + readyState = FakeWebSocket.CONNECTING; + sent: string[] = []; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(new Event("close") as CloseEvent); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(new Event("open")); + } + + emitMessage(data: string) { + this.onmessage?.(new MessageEvent("message", { data })); + } +} + async function act(callback: () => void | Promise) { await callback(); await Promise.resolve(); @@ -178,16 +332,42 @@ function createTemplate(overrides: Record = {}) { }; } +function supportedDaytonaCapabilities() { + return { + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }; +} + describe("CompanyEnvironments — test provider button", () => { let container: HTMLDivElement; let root: ReturnType | null; let probeResolvers: Map void>; + let originalWebSocket: typeof WebSocket | undefined; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = null; probeResolvers = new Map(); + originalWebSocket = globalThis.WebSocket; + FakeWebSocket.instances = []; + xtermMocks.reset(); + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true }); mockEnvironmentsApi.capabilities.mockResolvedValue({ adapters: [], sandboxProviders: {} }); @@ -205,6 +385,16 @@ describe("CompanyEnvironments — test provider button", () => { session: createSession(), connectionPayload: { type: "ssh", command: "ssh sandbox@setup.example.invalid" }, }); + mockEnvironmentsApi.createCustomImageTerminalSessionToken.mockResolvedValue({ + id: "terminal-session-1", + token: "terminal-token-terminal-token-123456", + expiresAt: "2026-06-25T20:05:00.000Z", + setupSessionId: "session-1", + environmentId: "env-1", + connectionType: "ssh", + websocketPath: + "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1", + }); mockEnvironmentsApi.finishCustomImageSetupSession.mockResolvedValue({ session: createSession({ status: "promoted", promotedTemplateId: "template-1", finishedAt: "2026-06-25T20:10:00.000Z" }), template: createTemplate(), @@ -255,6 +445,12 @@ describe("CompanyEnvironments — test provider button", () => { root = null; container.remove(); document.body.innerHTML = ""; + if (originalWebSocket) { + globalThis.WebSocket = originalWebSocket; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).WebSocket; + } vi.clearAllMocks(); }); @@ -568,6 +764,93 @@ describe("CompanyEnvironments — test provider button", () => { expect(getOpenDialog()?.textContent).not.toContain(command); }); + 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"; + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: createSession(), + latestSession: createSession(), + }); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "ssh", command }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain(command); + expect(getOpenDialog()?.textContent).toContain("Browser terminal"); + expect(getOpenDialog()?.textContent).toContain("SSH command fallback"); + expect(mockEnvironmentsApi.createCustomImageTerminalSessionToken).toHaveBeenCalledExactlyOnceWith("session-1", {}); + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + expect(FakeWebSocket.instances[0].url).toContain( + "/api/environment-custom-image-setup-sessions/session-1/terminal/ws?terminalSessionId=terminal-session-1", + ); + expect(FakeWebSocket.instances[0].url).not.toContain("token="); + expect(FakeWebSocket.instances[0].url).not.toContain("terminal-token-terminal-token-123456"); + expect(FakeWebSocket.instances[0].url).toContain("cols=120"); + expect(FakeWebSocket.instances[0].url).toContain("rows=32"); + expect(xtermMocks.terminalInstances[0].options.cursorBlink).toBe(true); + expect(xtermMocks.terminalInstances[0].options.cursorInactiveStyle).toBe("bar"); + expect(xtermMocks.terminalInstances[0].options.cursorStyle).toBe("bar"); + expect(xtermMocks.terminalInstances[0].options.cursorWidth).toBe(2); + expect(xtermMocks.terminalInstances[0].options.customGlyphs).toBe(true); + expect(xtermMocks.terminalInstances[0].options.letterSpacing).toBe(0); + expect(xtermMocks.terminalInstances[0].options.theme).toMatchObject({ + cursor: "#22d3ee", + cursorAccent: "#020617", + }); + expect(String(xtermMocks.terminalInstances[0].options.fontFamily)).toContain("Nerd Font"); + + await act(async () => { + FakeWebSocket.instances[0].open(); + FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "ready" })); + FakeWebSocket.instances[0].emitMessage(JSON.stringify({ type: "output", data: "\u001b[?2004hsetup shell\r\n$ " })); + }); + const terminalScreen = getOpenDialog()?.querySelector( + "[data-testid='custom-image-terminal-screen-session-1']", + ); + await waitForAssertion(() => { + expect(terminalScreen?.dataset.mockXtermOpen).toBe("true"); + expect(xtermMocks.terminalInstances[0].writes.join("")).toContain("setup shell"); + expect(xtermMocks.terminalInstances[0].focused).toBe(true); + expect(document.activeElement).toBe(terminalScreen); + expect(getOpenDialog()?.textContent).toContain(command); + }); + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ + type: "auth", + token: "terminal-token-terminal-token-123456", + })); + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "resize", cols: 120, rows: 32 })); + + await act(async () => { + xtermMocks.terminalInstances[0].emitData("l"); + xtermMocks.terminalInstances[0].emitData("\r"); + }); + + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "input", data: "l" })); + expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "input", data: "\r" })); + }); + it("does not render connect details when an active session refreshes as expired", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -621,6 +904,84 @@ describe("CompanyEnvironments — test provider button", () => { expect(getOpenDialog()?.textContent).not.toContain(command); }); + it("shows a setup connection refresh fallback without breaking finish or cancel", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: createSession(), + latestSession: createSession(), + }); + mockEnvironmentsApi.customImageSetupSession.mockRejectedValue(new Error("proxy unavailable")); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Setup connection details could not be refreshed."); + }); + + const dialog = getOpenDialog()!; + expect(findButton(dialog, "Finished")?.disabled).toBe(false); + expect(findButton(dialog, "Cancel")?.disabled).toBe(false); + + await act(async () => click(findButton(dialog, "Finished"))); + await flushReact(); + + expect(mockEnvironmentsApi.finishCustomImageSetupSession).toHaveBeenCalledExactlyOnceWith("session-1", {}); + }); + + it("shows provider fallback messaging for unsupported setup connection payloads", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue(supportedDaytonaCapabilities()); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: null, + activeSession: createSession(), + latestSession: createSession(), + }); + mockEnvironmentsApi.customImageSetupSession.mockResolvedValue({ + session: createSession(), + connectionPayload: { type: "browser_terminal", command: null }, + }); + + await act(async () => { + root!.render( + + + + + , + ); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(getOpenDialog()?.textContent).toContain("Browser terminal is not available for this provider connection."); + }); + + const dialog = getOpenDialog()!; + expect(findButton(dialog, "Finished")?.disabled).toBe(false); + expect(findButton(dialog, "Cancel")?.disabled).toBe(false); + }); + it("shows active template controls for refresh, rollback, and disable", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -678,5 +1039,11 @@ describe("CompanyEnvironments — test provider button", () => { "env-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 e8abcff00f..c19881491b 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -1,6 +1,14 @@ -import { useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Play, RefreshCw, RotateCcw, Trash2, X } from "lucide-react"; +import { Check, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal as XTermTerminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; import { type EnvBinding, type Environment, @@ -34,6 +42,7 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; +import { buildSameOriginWebSocketUrl } from "@/lib/websocket-url"; import { Field, ToggleField, @@ -202,6 +211,427 @@ function readConnectionCommand(payload: EnvironmentCustomImageConnectionPayload : null; } +function setupConnectionFallbackMessage(input: { + payload: EnvironmentCustomImageConnectionPayload | null; + refreshError: unknown; + isLoading: boolean; +}): string | null { + if (input.refreshError) { + return "Setup connection details could not be refreshed. You can still finish or cancel this setup."; + } + if (input.isLoading) return null; + if (!input.payload) { + return "Connection details are not available yet. You can still finish or cancel this setup."; + } + if (input.payload.type !== "ssh") { + return "Browser terminal is not available for this provider connection. Use the provider setup instructions, then finish or cancel here."; + } + if (!readConnectionCommand(input.payload)) { + return "Connection details are not available yet. You can still finish or cancel this setup."; + } + return null; +} + +const CUSTOM_IMAGE_TERMINAL_COLS = 100; +const CUSTOM_IMAGE_TERMINAL_ROWS = 28; +const CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS = 5_000; +const CUSTOM_IMAGE_TERMINAL_FONT_FAMILY = [ + "MesloLGS NF", + "MesloLGS Nerd Font Mono", + "CaskaydiaCove Nerd Font Mono", + "CaskaydiaMono Nerd Font", + "JetBrainsMono Nerd Font", + "FiraCode Nerd Font Mono", + "Symbols Nerd Font Mono", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "monospace", +].join(", "); + +type CustomImageTerminalConnectionState = + | "idle" + | "connecting" + | "connected" + | "closed" + | "error"; + +function appendTerminalQuery(path: string, params: Record) { + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}${new URLSearchParams( + Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)])), + ).toString()}`; +} + +function parseTerminalFrame(raw: string): Record | null { + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function customImageTerminalStatusCopy(state: CustomImageTerminalConnectionState) { + switch (state) { + case "connecting": + return "Connecting"; + case "connected": + return "Connected"; + case "closed": + return "Closed"; + case "error": + return "Connection failed"; + case "idle": + default: + return "Ready to connect"; + } +} + +function customImageTerminalCloseReasonCopy(reason: unknown) { + if ( + reason !== "expired" + && reason !== "ssh_closed" + && reason !== "server_shutdown" + && reason !== "setup_finished" + && reason !== "setup_cancelled" + ) { + return typeof reason === "string" && reason.trim() ? "Terminal closed." : null; + } + + switch (reason) { + case "expired": + return "Setup session expired."; + case "ssh_closed": + return "SSH session closed."; + case "server_shutdown": + return "Terminal server shut down."; + case "setup_finished": + return "Setup session finished."; + case "setup_cancelled": + return "Setup session cancelled."; + default: + return null; + } +} + +function EnvironmentCustomImageBrowserTerminal({ + autoConnect = false, + sessionId, +}: { + autoConnect?: boolean; + sessionId: string; +}) { + const [connectionState, setConnectionState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const terminalElementRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null); + const resizeObserverRef = useRef(null); + const fitFrameRef = useRef(null); + const socketRef = useRef(null); + const autoConnectAttemptedSessionRef = useRef(null); + const lastSentResizeRef = useRef<{ cols: number; rows: number } | null>(null); + + const closeSocket = useCallback((reason = "operator_closed") => { + const socket = socketRef.current; + socketRef.current = null; + if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) { + socket.close(1000, reason); + } + }, []); + + const getTerminalDimensions = useCallback(() => { + const terminal = xtermRef.current; + return { + cols: terminal?.cols || CUSTOM_IMAGE_TERMINAL_COLS, + rows: terminal?.rows || CUSTOM_IMAGE_TERMINAL_ROWS, + }; + }, []); + + const sendTerminalResize = useCallback((force = false) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + + const dimensions = getTerminalDimensions(); + const previous = lastSentResizeRef.current; + if (!force && previous?.cols === dimensions.cols && previous.rows === dimensions.rows) return; + + lastSentResizeRef.current = dimensions; + socket.send(JSON.stringify({ + type: "resize", + cols: dimensions.cols, + rows: dimensions.rows, + })); + }, [getTerminalDimensions]); + + const fitTerminal = useCallback(() => { + const fitAddon = fitAddonRef.current; + if (!fitAddon || !xtermRef.current) return; + try { + fitAddon.fit(); + sendTerminalResize(); + } catch { + // The fit addon can throw during hidden/dialog layout transitions. The + // next ResizeObserver tick or reconnect will retry with stable dimensions. + } + }, [sendTerminalResize]); + + const requestFitTerminal = useCallback(() => { + if (fitFrameRef.current !== null) { + window.cancelAnimationFrame(fitFrameRef.current); + } + fitFrameRef.current = window.requestAnimationFrame(() => { + fitFrameRef.current = null; + fitTerminal(); + }); + }, [fitTerminal]); + + const sendTerminalInput = useCallback((data: string) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }, []); + + const resetTerminalScreen = useCallback(() => { + const terminal = xtermRef.current; + if (!terminal) return; + terminal.reset(); + terminal.clear(); + }, []); + + useEffect(() => { + const element = terminalElementRef.current; + if (!element || xtermRef.current) return undefined; + + const terminal = new XTermTerminal({ + allowTransparency: true, + cols: CUSTOM_IMAGE_TERMINAL_COLS, + rows: CUSTOM_IMAGE_TERMINAL_ROWS, + convertEol: false, + cursorBlink: true, + cursorInactiveStyle: "bar", + cursorStyle: "bar", + cursorWidth: 2, + customGlyphs: true, + fontFamily: CUSTOM_IMAGE_TERMINAL_FONT_FAMILY, + fontSize: 12, + letterSpacing: 0, + lineHeight: 1.35, + scrollback: CUSTOM_IMAGE_TERMINAL_SCROLLBACK_ROWS, + theme: { + background: "#0a0a0a", + foreground: "#f5f5f5", + cursor: "#22d3ee", + cursorAccent: "#020617", + selectionBackground: "#2563eb55", + }, + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(element); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + terminalInputDisposableRef.current = terminal.onData(sendTerminalInput); + + if (typeof ResizeObserver !== "undefined") { + const resizeObserver = new ResizeObserver(() => requestFitTerminal()); + resizeObserver.observe(element); + resizeObserverRef.current = resizeObserver; + } + + terminal.focus(); + requestFitTerminal(); + const fitTimeouts = [50, 250].map((delay) => window.setTimeout(fitTerminal, delay)); + const fontsReady = "fonts" in document + ? (document as Document & { fonts?: { ready?: Promise } }).fonts?.ready + : null; + if (fontsReady) { + void fontsReady.then(() => fitTerminal()); + } + + return () => { + if (fitFrameRef.current !== null) { + window.cancelAnimationFrame(fitFrameRef.current); + fitFrameRef.current = null; + } + for (const timeoutId of fitTimeouts) { + window.clearTimeout(timeoutId); + } + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + terminalInputDisposableRef.current?.dispose(); + terminalInputDisposableRef.current = null; + fitAddonRef.current = null; + xtermRef.current = null; + terminal.dispose(); + }; + }, [fitTerminal, requestFitTerminal, sendTerminalInput]); + + useEffect(() => () => closeSocket("component_unmounted"), [closeSocket]); + + useEffect(() => { + closeSocket("session_changed"); + autoConnectAttemptedSessionRef.current = null; + lastSentResizeRef.current = null; + setConnectionState("idle"); + setErrorMessage(null); + resetTerminalScreen(); + }, [closeSocket, resetTerminalScreen, sessionId]); + + useEffect(() => { + if (connectionState === "connected") { + xtermRef.current?.focus(); + } + }, [connectionState]); + + const connectTerminal = useCallback(async () => { + if (typeof WebSocket === "undefined") { + setConnectionState("error"); + setErrorMessage("Browser terminal is unavailable in this browser."); + return; + } + + closeSocket("reconnect"); + setConnectionState("connecting"); + lastSentResizeRef.current = null; + setErrorMessage(null); + resetTerminalScreen(); + xtermRef.current?.focus(); + + try { + fitTerminal(); + const dimensions = getTerminalDimensions(); + const terminalToken = await environmentsApi.createCustomImageTerminalSessionToken(sessionId, {}); + const websocketPath = appendTerminalQuery(terminalToken.websocketPath, { + cols: dimensions.cols, + rows: dimensions.rows, + }); + const socket = new WebSocket(buildSameOriginWebSocketUrl(websocketPath)); + socketRef.current = socket; + + socket.onopen = () => { + if (socketRef.current !== socket) return; + xtermRef.current?.focus(); + socket.send(JSON.stringify({ type: "auth", token: terminalToken.token })); + sendTerminalResize(true); + }; + + socket.onmessage = (message) => { + if (socketRef.current !== socket) return; + const raw = typeof message.data === "string" ? message.data : ""; + const frame = raw ? parseTerminalFrame(raw) : null; + if (!frame) return; + + if (frame.type === "ready") { + setConnectionState("connected"); + xtermRef.current?.focus(); + return; + } + + if (frame.type === "output" && typeof frame.data === "string") { + xtermRef.current?.write(frame.data as string); + return; + } + + if (frame.type === "error") { + setConnectionState("error"); + setErrorMessage(typeof frame.message === "string" ? frame.message : "Terminal connection failed."); + return; + } + + if (frame.type === "closed") { + setConnectionState("closed"); + setErrorMessage(customImageTerminalCloseReasonCopy(frame.reason)); + } + }; + + socket.onclose = () => { + if (socketRef.current !== socket) return; + socketRef.current = null; + setConnectionState((current) => current === "connected" || current === "connecting" ? "closed" : current); + }; + + socket.onerror = () => { + if (socketRef.current !== socket) return; + setConnectionState("error"); + setErrorMessage("Terminal websocket connection failed."); + }; + } catch (error) { + setConnectionState("error"); + setErrorMessage(error instanceof Error ? error.message : "Terminal session could not be opened."); + } + }, [closeSocket, fitTerminal, getTerminalDimensions, resetTerminalScreen, sendTerminalResize, 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 disconnectTerminal = useCallback(() => { + closeSocket("operator_closed"); + setConnectionState("closed"); + }, [closeSocket]); + + const terminalInteractive = connectionState === "connected"; + + return ( +
+
+
+ + Browser terminal + {customImageTerminalStatusCopy(connectionState)} +
+
+ {terminalInteractive ? ( + + ) : ( + + )} +
+
+
+
xtermRef.current?.focus()} + onClick={() => xtermRef.current?.focus()} + className="h-[18rem] w-full overflow-hidden bg-neutral-950 outline-none sm:h-[22rem] [&_.xterm-cursor-bar]:!border-l-2 [&_.xterm-cursor-bar]:!border-l-cyan-300 [&_.xterm-cursor-layer_.xterm-cursor]:!bg-cyan-300 [&_.xterm-helper-textarea]:!opacity-0 [&_.xterm-screen]:focus:outline-none [&_.xterm-viewport]:!overflow-y-auto [&_.xterm]:h-full" + /> +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ ); +} + function capabilityState(capability: EnvironmentProviderCapability | null | undefined) { if (!capability || capability.status !== "supported" || !capability.supportsInteractiveSetup) { return { @@ -301,7 +731,6 @@ function EnvironmentImageTemplatePanel({ latestSession: result.session, })); setSessionResult(result); - invalidateOverview(); pushToast({ title: "Setup session started", body: "Connect details are available while the session is active.", @@ -452,6 +881,13 @@ function EnvironmentImageTemplatePanel({ ? sessionQuery.data?.connectionPayload ?? null : null; const connectionCommand = readConnectionCommand(connectionPayload); + const connectionFallbackMessage = session?.status === "waiting_for_user" + ? setupConnectionFallbackMessage({ + payload: connectionPayload, + refreshError: sessionQuery.isError ? sessionQuery.error : null, + isLoading: sessionQuery.isLoading, + }) + : null; const sessionExpiresAt = formatDateTime(connectionPayload?.expiresAt ?? session?.expiresAt ?? null); const capturedAt = formatDateTime(activeTemplate?.capturedAt ?? activeTemplate?.createdAt ?? null); const lastUsedAt = formatDateTime(activeTemplate?.lastUsedAt ?? null); @@ -494,16 +930,22 @@ 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" && !connectionCommand ? ( + {session.status === "waiting_for_user" && connectionFallbackMessage ? (
- Connection details are not available yet. + {connectionFallbackMessage}
) : null} {session.failureReason ? (