Harden custom image setup terminal fallback

This commit is contained in:
Cody committed 2026-07-01 17:15:16 +00:00
1 parent b97afa57aa
commit f9188e1976
14 files changed
+1611 -48

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ export const API = {
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`,
@@ -101,11 +101,13 @@ export type CreateEnvironmentCustomImageTerminalSessionToken =
z.infer<typeof createEnvironmentCustomImageTerminalSessionTokenSchema>;
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<typeof environmentCustomImageTerminalSessionTokenSchema>;
+1
View File
@@ -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"
},
@@ -2,7 +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 { environmentCustomImageTerminalSessionStore } from "../services/environment-custom-image-terminal-sessions.js";
import {
environmentCustomImageTerminalConnectionRegistry,
environmentCustomImageTerminalSessionStore,
} from "../services/environment-custom-image-terminal-sessions.js";
const now = new Date("2026-06-25T20:00:00.000Z");
@@ -232,6 +235,7 @@ describe("environment customImage setup routes", () => {
Object.values(mockSecretService).forEach((mock) => mock.mockReset());
mockLogActivity.mockReset();
environmentCustomImageTerminalSessionStore.clear();
environmentCustomImageTerminalConnectionRegistry.clear();
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockEnvironmentService.getById.mockResolvedValue(createEnvironment());
@@ -355,8 +359,12 @@ describe("environment customImage setup routes", () => {
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(mockEnvironmentCustomImageService.refreshSetupSession).toHaveBeenCalledWith({
sessionId: "session-1",
includeConnectionPayload: true,
@@ -472,6 +480,43 @@ describe("environment customImage setup routes", () => {
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)
@@ -517,6 +562,19 @@ describe("environment customImage setup routes", () => {
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")
@@ -528,6 +586,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");
@@ -0,0 +1,427 @@
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<string, unknown> = {}) {
return {
id: "session-1",
companyId: "company-1",
environmentId: "env-1",
provider: "daytona",
status: "waiting_for_user",
expiresAt: futureDate(),
...overrides,
};
}
async function flushPromises() {
await new Promise<void>((resolve) => setImmediate(resolve));
}
async function listen(server: HttpServer) {
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()));
}
function waitForOpen(ws: InstanceType<typeof WebSocket>) {
return new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
}
function waitForError(ws: InstanceType<typeof WebSocket>) {
return new Promise<Error>((resolve) => {
ws.on("error", resolve);
});
}
function waitForClose(ws: InstanceType<typeof WebSocket>) {
return new Promise<void>((resolve) => {
ws.on("close", resolve);
});
}
function waitForJsonMessage<T extends Record<string, unknown>>(
ws: InstanceType<typeof WebSocket>,
predicate: (frame: T) => boolean,
) {
return new Promise<T>((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 terminalUrl(port: number, input: { setupSessionId?: string; terminalSessionId: string; token: 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)}&token=${encodeURIComponent(input.token)}`
+ "&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<typeof vi.fn>;
refreshSetupSession: ReturnType<typeof vi.fn>;
};
let connector: EnvironmentCustomImageSshConnector & { connect: ReturnType<typeof vi.fn> };
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",
token: "bad-token",
}));
const invalidError = await waitForError(invalid);
expect(invalidError.message).toContain("422");
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,
token: expired.token,
}));
const expiredError = await waitForError(expiredWs);
expect(expiredError.message).toContain("422");
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,
token: unsupportedPayload.token,
}));
const unsupportedError = await waitForError(unsupportedWs);
expect(unsupportedError.message).toContain("422");
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,
token: unsupportedCommand.token,
}));
const unsupportedCommandError = await waitForError(unsupportedCommandWs);
expect(unsupportedCommandError.message).toContain("422");
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,
token: minted.token,
}));
const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready");
await waitForOpen(ws);
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,
});
ws.send(JSON.stringify({ type: "input", data: "echo ok\r" }));
await flushPromises();
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 flushPromises();
expect(fakeShell.resizes).toEqual([{ cols: 120, rows: 40 }]);
const closePromise = waitForClose(ws);
ws.close();
await closePromise;
expect(fakeShell.closeCalls).toBeGreaterThan(0);
expect(sessionStore.get({ id: minted.session.id, token: minted.token })).toBeNull();
});
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,
token: minted.token,
}));
const errorPromise = waitForJsonMessage(ws, (frame) => frame.type === "error");
const closePromise = waitForClose(ws);
await waitForOpen(ws);
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,
token: minted.token,
}));
const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready");
await waitForOpen(ws);
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,
token: minted.token,
}));
const readyPromise = waitForJsonMessage(ws, (frame) => frame.type === "ready");
await waitForOpen(ws);
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);
});
});
+4
View File
@@ -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<StartedServer> {
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,
@@ -0,0 +1,624 @@
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 { 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<TerminalWsSocket>;
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;
companyId: string;
environmentId: string;
provider: string;
status: string;
expiresAt: Date | string | null;
}
interface CustomImageTerminalService {
getSessionById(sessionId: string): Promise<SetupSessionSnapshot | null>;
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;
}): Promise<EnvironmentCustomImageSshShell>;
}
interface TerminalUpgradeContext {
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;
};
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 readDate(value: unknown): Date | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
}
function requireFutureSetupExpiry(session: { expiresAt: Date | string | null }, now: Date): Date {
const expiresAt = readDate(session.expiresAt);
if (!expiresAt || expiresAt.getTime() <= now.getTime()) {
throw conflict("Environment customImage setup session has expired.");
}
return expiresAt;
}
function statusLineForError(err: unknown) {
const status = typeof err === "object" && err !== null && "status" in err
? Number((err as { status?: unknown }).status)
: 500;
if (status === 400) return "400 Bad Request";
if (status === 401) return "401 Unauthorized";
if (status === 403) return "403 Forbidden";
if (status === 404) return "404 Not Found";
if (status === 409) return "409 Conflict";
if (status === 422) return "422 Unprocessable Entity";
return "500 Internal Server Error";
}
function safeErrorName(err: unknown) {
return err instanceof Error ? err.name : typeof err;
}
function terminalPayloadValidationError(
failure: Extract<EnvironmentCustomImageTerminalPayloadValidationResult, { ok: false }>,
): 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<string, unknown>) {
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 handleClientFrame(
socket: TerminalWsSocket,
shell: EnvironmentCustomImageSshShell | null,
raw: unknown,
) {
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<string, unknown>;
if (frame.type === "input") {
if (typeof frame.data === "string") {
shell?.write(frame.data);
}
return;
}
if (frame.type === "resize") {
const cols = typeof frame.cols === "number" ? frame.cols : null;
const rows = typeof frame.rows === "number" ? frame.rows : null;
if (
shell
&& Number.isInteger(cols)
&& Number.isInteger(rows)
&& cols > 0
&& rows > 0
&& cols <= 9999
&& rows <= 9999
) {
shell.resize(cols, rows);
}
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<EnvironmentCustomImageTerminalSessionRecord & { ssh: ParsedCustomImageSetupSshCommand }> {
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.");
}
if (
storedSetupSession.companyId !== 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
|| refreshed.session.companyId !== 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}".`);
}
requireFutureSetupExpiry(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 };
}
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 }) => {
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<string, unknown>): void;
shell(
window: { term: string; cols: number; rows: number },
callback: (err: Error | undefined, stream: ConstructorParameters<typeof Ssh2Shell>[1]) => void,
): void;
end(): void;
destroy?(): void;
};
};
return await new Promise<EnvironmentCustomImageSshShell>((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 }, (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,
username: ssh.username,
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 context = (req as IncomingMessageWithTerminalContext).paperclipTerminalUpgradeContext;
if (!context) {
socket.close(1008, "missing context");
return;
}
let shell: EnvironmentCustomImageSshShell | null = null;
let cleanupRegistry: (() => void) | null = null;
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
let cleanedUp = false;
const cleanup = (reason: string) => {
if (cleanedUp) return;
cleanedUp = true;
if (expiryTimer) clearTimeout(expiryTimer);
cleanupRegistry?.();
cleanupRegistry = null;
sessionStore.delete(context.terminalSession.id);
if (shell) {
shell.close();
shell = null;
}
logger.info({
setupSessionId: context.setupSessionId,
terminalSessionId: context.terminalSession.id,
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);
};
cleanupRegistry = connectionRegistry.add({
setupSessionId: context.setupSessionId,
close: (reason) => {
closeTerminal(reason);
},
});
const expiresInMs = Math.max(0, context.terminalSession.expiresAt.getTime() - Date.now());
expiryTimer = setTimeout(() => {
closeTerminal("expired", 1008, "expired");
}, expiresInMs);
socket.on("message", (data: unknown) => {
handleClientFrame(socket, shell, data);
});
socket.on("close", () => {
cleanup("client_closed");
});
socket.on("error", () => {
cleanup("client_error");
});
void sshConnector.connect({
ssh: context.ssh,
term: "xterm-256color",
cols: context.initialCols,
rows: context.initialRows,
})
.then((connectedShell) => {
if (cleanedUp) {
connectedShell.close();
return;
}
shell = connectedShell;
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");
});
});
wss.on("close", () => {
connectionRegistry.closeAll("server_shutdown");
});
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 onRawSocketError = (err: Error) => {
logger.warn({ errorName: safeErrorName(err), path: req.url }, "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 token = url.searchParams.get("token")?.trim() ?? "";
const initialCols = parseTerminalDimension(url.searchParams.get("cols"), 80);
const initialRows = parseTerminalDimension(url.searchParams.get("rows"), 24);
if (!terminalSessionId || !token) {
rejectUpgrade(socket, "400 Bad Request", "missing terminal credentials");
return;
}
void validateTerminalUpgrade({
setupSessionId: path.setupSessionId,
terminalSessionId,
token,
now: new Date(),
sessionStore,
customImages,
})
.then((terminalSession) => {
if (!isWritableUpgradeSocket(socket)) {
cleanupRawSocketListeners();
return;
}
reqWithContext.paperclipTerminalUpgradeContext = {
setupSessionId: path.setupSessionId,
terminalSession,
ssh: terminalSession.ssh,
initialCols,
initialRows,
};
cleanupRawSocketListeners();
wss.handleUpgrade(req, socket, head, (ws: TerminalWsSocket) => {
wss.emit("connection", ws, reqWithContext);
});
})
.catch((err) => {
logger.warn({ errorName: safeErrorName(err), path: req.url }, "custom image terminal websocket upgrade rejected");
rejectUpgrade(socket, statusLineForError(err), "terminal websocket upgrade rejected");
});
});
return wss;
}
+5
View File
@@ -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");
};
+23 -25
View File
@@ -23,8 +23,10 @@ import {
projectService,
} from "../services/index.js";
import {
environmentCustomImageTerminalConnectionRegistry,
environmentCustomImageTerminalSessionStore,
parseCustomImageSetupSshCommand,
validateCustomImageSetupSshPayload,
type EnvironmentCustomImageTerminalPayloadValidationResult,
} from "../services/environment-custom-image-terminal-sessions.js";
import {
collectEnvironmentSecretRefs,
@@ -312,10 +314,13 @@ export function environmentRoutes(
return expiresAt;
}
function readConnectionPayload(payload: unknown): Record<string, unknown> | null {
return payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: null;
function throwTerminalPayloadValidationFailure(
failure: Extract<EnvironmentCustomImageTerminalPayloadValidationResult, { ok: false }>,
): never {
if (failure.status === 409) {
throw conflict(failure.message);
}
throw unprocessable(failure.message);
}
router.get("/companies/:companyId/environments", async (req, res) => {
@@ -436,24 +441,9 @@ export function environmentRoutes(
throw conflict(`Cannot create terminal session token from setup status "${refreshed.session.status}".`);
}
const setupExpiresAt = requireFutureSetupExpiry(refreshed.session, now);
const payload = readConnectionPayload(refreshed.connectionPayload);
if (!payload || payload.type !== "ssh") {
throw unprocessable("Setup session terminal connections require an SSH connection payload.");
}
const command = typeof payload.command === "string" ? payload.command.trim() : "";
if (!command) {
throw unprocessable("Setup session SSH payload is missing a supported command.");
}
const ssh = parseCustomImageSetupSshCommand(command);
if (!ssh) {
throw unprocessable("Setup session SSH payload uses an unsupported command shape.");
}
const connectionExpiresAt = readDate(payload.expiresAt);
if (payload.expiresAt != null && !connectionExpiresAt) {
throw unprocessable("Setup session SSH payload has an invalid expiry.");
}
if (connectionExpiresAt && connectionExpiresAt.getTime() <= now.getTime()) {
throw conflict("Setup session SSH connection payload has expired.");
const payloadValidation = validateCustomImageSetupSshPayload(refreshed.connectionPayload, now);
if (!payloadValidation.ok) {
throwTerminalPayloadValidationFailure(payloadValidation);
}
const minted = environmentCustomImageTerminalSessionStore.create({
@@ -461,9 +451,9 @@ export function environmentRoutes(
companyId: refreshed.session.companyId,
environmentId: refreshed.session.environmentId,
provider: refreshed.session.provider,
ssh,
ssh: payloadValidation.ssh,
setupExpiresAt,
connectionExpiresAt,
connectionExpiresAt: payloadValidation.connectionExpiresAt,
now,
});
const actor = getActorInfo(req);
@@ -481,11 +471,15 @@ export function environmentRoutes(
},
});
res.status(201).json({
id: minted.session.id,
token: minted.token,
expiresAt: minted.session.expiresAt.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)}&token=${encodeURIComponent(minted.token)}`,
});
},
);
@@ -506,6 +500,8 @@ export function environmentRoutes(
sessionId: session.id,
metadata: req.body.metadata,
});
environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id);
environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_finished");
await logEnvironmentCustomImageActivity({
actor,
companyId: session.companyId,
@@ -536,6 +532,8 @@ export function environmentRoutes(
sessionId: session.id,
reason: req.body.reason ?? null,
});
environmentCustomImageTerminalSessionStore.deleteBySetupSessionId(session.id);
environmentCustomImageTerminalConnectionRegistry.closeBySetupSessionId(session.id, "setup_cancelled");
await logEnvironmentCustomImageActivity({
actor,
companyId: session.companyId,
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
EnvironmentCustomImageTerminalConnectionRegistry,
EnvironmentCustomImageTerminalSessionStore,
parseCustomImageSetupSshCommand,
validateCustomImageSetupSshPayload,
} from "./environment-custom-image-terminal-sessions.js";
describe("parseCustomImageSetupSshCommand", () => {
@@ -23,6 +25,14 @@ describe("parseCustomImageSetupSshCommand", () => {
});
});
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();
@@ -33,6 +43,70 @@ describe("parseCustomImageSetupSshCommand", () => {
});
});
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 tokens and caps expiry by setup and provider payload expiry", () => {
const store = new EnvironmentCustomImageTerminalSessionStore();
@@ -49,12 +123,75 @@ describe("EnvironmentCustomImageTerminalSessionStore", () => {
});
expect(minted.token).toHaveLength(43);
expect(minted.session.id).toMatch(/^[0-9a-f-]{36}$/);
expect(minted.session.expiresAt.toISOString()).toBe("2026-06-25T20:02:00.000Z");
expect(store.get(minted.token, new Date("2026-06-25T20:01:59.000Z"))?.ssh).toEqual({
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(minted.token, new Date("2026-06-25T20:02:00.000Z"))).toBeNull();
expect(store.get({
id: minted.session.id,
token: "wrong-token",
}, new Date("2026-06-25T20:01:59.000Z"))).toBeNull();
expect(store.get({
id: minted.session.id,
token: minted.token,
}, new Date("2026-06-25T20:02:00.000Z"))).toBeNull();
});
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"]);
});
});
@@ -1,4 +1,4 @@
import { createHash, randomBytes } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
const DEFAULT_TERMINAL_SESSION_TOKEN_TTL_MS = 5 * 60 * 1000;
const TERMINAL_SESSION_TOKEN_BYTES = 32;
@@ -9,7 +9,28 @@ export interface ParsedCustomImageSetupSshCommand {
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;
@@ -25,6 +46,11 @@ export interface MintedEnvironmentCustomImageTerminalSession {
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);
@@ -68,6 +94,73 @@ export function parseCustomImageSetupSshCommand(command: string): ParsedCustomIm
return null;
}
function readConnectionPayload(payload: unknown): Record<string, unknown> | null {
return payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: null;
}
function readDate(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 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 = readDate(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");
}
@@ -84,7 +177,7 @@ function toValidFutureDate(value: Date | string | null | undefined, now: Date):
}
export class EnvironmentCustomImageTerminalSessionStore {
private readonly sessions = new Map<string, EnvironmentCustomImageTerminalSessionRecord>();
private readonly sessionsById = new Map<string, StoredEnvironmentCustomImageTerminalSession>();
create(input: {
setupSessionId: string;
@@ -106,7 +199,9 @@ export class EnvironmentCustomImageTerminalSessionStore {
].filter((date): date is Date => date !== null);
const expiresAt = 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,
@@ -116,32 +211,57 @@ export class EnvironmentCustomImageTerminalSessionStore {
createdAt: now,
expiresAt,
};
this.sessions.set(hashTerminalSessionToken(token), session);
this.sessionsById.set(id, {
tokenHash: hashTerminalSessionToken(token),
session,
});
return { token, session };
}
get(token: string, now = new Date()): EnvironmentCustomImageTerminalSessionRecord | null {
if (!token) return null;
const key = hashTerminalSessionToken(token);
const session = this.sessions.get(key) ?? null;
if (!session) return null;
if (session.expiresAt.getTime() <= now.getTime()) {
this.sessions.delete(key);
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.expiresAt.getTime() <= now.getTime()) {
this.sessionsById.delete(input.id);
return null;
}
return session;
return stored.session;
}
delete(token: string): boolean {
if (!token) return false;
return this.sessions.delete(hashTerminalSessionToken(token));
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.expiresAt.getTime() <= now.getTime()) {
this.sessionsById.delete(id);
return null;
}
return stored.session;
}
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 [key, session] of this.sessions) {
if (session.expiresAt.getTime() <= now.getTime()) {
this.sessions.delete(key);
for (const [id, stored] of this.sessionsById) {
if (stored.session.expiresAt.getTime() <= now.getTime()) {
this.sessionsById.delete(id);
removed += 1;
}
}
@@ -149,9 +269,60 @@ export class EnvironmentCustomImageTerminalSessionStore {
}
clear(): void {
this.sessions.clear();
this.sessionsById.clear();
}
}
export const environmentCustomImageTerminalSessionStore =
new EnvironmentCustomImageTerminalSessionStore();
export type EnvironmentCustomImageTerminalConnectionClose = (reason: string) => void;
export class EnvironmentCustomImageTerminalConnectionRegistry {
private readonly connectionsBySetupSessionId = new Map<string, Set<EnvironmentCustomImageTerminalConnectionClose>>();
add(input: {
setupSessionId: string;
close: EnvironmentCustomImageTerminalConnectionClose;
}): () => void {
const existing = this.connectionsBySetupSessionId.get(input.setupSessionId);
const connections = existing ?? new Set<EnvironmentCustomImageTerminalConnectionClose>();
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();
+3
View File
@@ -90,9 +90,12 @@ export {
environmentCustomImageService,
} from "./environment-custom-images.js";
export {
environmentCustomImageTerminalConnectionRegistry,
environmentCustomImageTerminalSessionStore,
EnvironmentCustomImageTerminalConnectionRegistry,
EnvironmentCustomImageTerminalSessionStore,
parseCustomImageSetupSshCommand,
type EnvironmentCustomImageTerminalConnectionClose,
type EnvironmentCustomImageTerminalSessionRecord,
type MintedEnvironmentCustomImageTerminalSession,
type ParsedCustomImageSetupSshCommand,
+99
View File
@@ -172,6 +172,27 @@ function createTemplate(overrides: Record<string, unknown> = {}) {
};
}
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<typeof createRoot> | null;
@@ -601,6 +622,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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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 } } });
+30 -2
View File
@@ -199,6 +199,27 @@ 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;
}
function capabilityState(capability: EnvironmentProviderCapability | null | undefined) {
if (!capability || capability.status !== "supported" || !capability.supportsInteractiveSetup) {
return {
@@ -451,6 +472,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);
@@ -500,9 +528,9 @@ function EnvironmentImageTemplatePanel({
</code>
</div>
) : null}
{session.status === "waiting_for_user" && !connectionCommand ? (
{session.status === "waiting_for_user" && connectionFallbackMessage ? (
<div className="mt-2 text-xs text-muted-foreground">
Connection details are not available yet.
{connectionFallbackMessage}
</div>
) : null}
{session.failureReason ? (