Add browser SSH terminal for custom image setup (#8911)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-07-03 16:44:21 -07:00 committed by GitHub
parent 47448721e1
commit bcac517f3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 3303 additions and 31 deletions

View File

@ -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`,

View File

@ -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,

View File

@ -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),

View File

@ -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";

View File

@ -93,3 +93,19 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({
}).strict();
export type CancelEnvironmentCustomImageSetupSession =
z.infer<typeof cancelEnvironmentCustomImageSetupSessionSchema>;
export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({});
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>;

View File

@ -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,

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"
},

View File

@ -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<string, unknown> = {}) {
function createTemplate(overrides: Record<string, unknown> = {}) {
return {
id: "template-1",
companyId: "company-1",
environmentId: "env-1",
provider: "daytona",
templateKind: "snapshot",
@ -142,6 +147,7 @@ function createTemplate(overrides: Record<string, unknown> = {}) {
function createSession(overrides: Record<string, unknown> = {}) {
return {
id: "session-1",
companyId: "company-1",
environmentId: "env-1",
templateId: "template-1",
promotedTemplateId: null,
@ -164,6 +170,7 @@ function createSession(overrides: Record<string, unknown> = {}) {
},
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);

View File

@ -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<string, unknown> = {}) {
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<void>((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<void>((resolve) => setTimeout(resolve, ms));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (err: Error) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
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 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 sendTerminalAuth(ws: InstanceType<typeof WebSocket>, 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<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",
}));
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<EnvironmentCustomImageSshShell>();
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);
});
});

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,

View File

@ -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<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;
environmentId: string;
provider: string;
status: string;
expiresAt: Date | string | null;
metadata?: Record<string, unknown> | 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;
verifyHostKeySha256: (hostKeySha256: string) => boolean;
}): Promise<EnvironmentCustomImageSshShell>;
}
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<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 readResizeDimensions(frame: Record<string, unknown>): { 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<string, unknown> | 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<string, unknown>
: 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<string, unknown>;
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<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.");
}
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<string, unknown>): void;
shell(
window: { term: string; cols: number; rows: number },
options: { env?: Record<string, string> },
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 }, { 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<typeof setTimeout> | null = null;
let authTimer: ReturnType<typeof setTimeout> | 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;
}

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");
};

View File

@ -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<typeof getActorInfo>;
companyId: string;
action: string;
entityId: string;
details: Record<string, unknown>;
}) {
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<string> {
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<string, unknown> | null },
): Promise<string> {
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<EnvironmentCustomImageTerminalPayloadValidationResult, { ok: false }>,
): 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),

View File

@ -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",

View File

@ -0,0 +1,32 @@
import { conflict } from "../errors.js";
export function readCustomImageSetupSessionCompanyId(session: {
metadata?: Record<string, unknown> | 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;
}

View File

@ -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"]);
});
});

View File

@ -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<ParsedCustomImageSetupSshCommand, "username" | "host"> | 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<string, unknown> | null {
return payload && typeof payload === "object" && !Array.isArray(payload)
? payload as Record<string, unknown>
: 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<string, StoredEnvironmentCustomImageTerminalSession>();
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<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();

View File

@ -120,7 +120,7 @@ function normalizeConnectionSummary(
const label = readString((summary as unknown as Record<string, unknown>).label);
return {
type: readConnectionType(summary.type),
username: readString(summary.username),
username: null,
hostRedacted: true,
portRedacted: true,
...(label ? { label } : {}),

View File

@ -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";

View File

@ -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",

View File

@ -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<EnvironmentCustomImageSetupSessionResult>(
`/environment-custom-image-setup-sessions/${sessionId}`,
),
createCustomImageTerminalSessionToken: (
sessionId: string,
body: CreateEnvironmentCustomImageTerminalSessionToken = {},
) =>
api.post<EnvironmentCustomImageTerminalSessionToken>(
`/environment-custom-image-setup-sessions/${sessionId}/terminal-session-token`,
body,
),
finishCustomImageSetupSession: (
sessionId: string,
body: FinishEnvironmentCustomImageSetupSession = {},

View File

@ -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<string, unknown>;
cols: number;
rows: number;
writes: string[] = [];
focused = false;
disposed = false;
openedElement: HTMLElement | null = null;
private readonly dataHandlers: Array<(data: string) => void> = [];
constructor(options: Record<string, unknown> = {}) {
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<void>) {
await callback();
await Promise.resolve();
@ -178,16 +332,42 @@ 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;
let probeResolvers: Map<string, () => 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(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
</TooltipProvider>
</QueryClientProvider>,
);
});
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<HTMLElement>(
"[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(
<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 } } });
@ -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);
});
});
});

View File

@ -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<string, string | number>) {
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<string, unknown> | null {
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: 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<CustomImageTerminalConnectionState>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const terminalElementRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTermTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalInputDisposableRef = useRef<{ dispose: () => void } | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const fitFrameRef = useRef<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const autoConnectAttemptedSessionRef = useRef<string | null>(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<unknown> } }).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 (
<div className="mt-3 rounded-md border border-border/70 bg-background" data-testid={`custom-image-terminal-${sessionId}`}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<div className="flex min-w-0 items-center gap-2 text-xs">
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Browser terminal</span>
<span className="text-muted-foreground">{customImageTerminalStatusCopy(connectionState)}</span>
</div>
<div className="flex items-center gap-2">
{terminalInteractive ? (
<Button size="sm" variant="ghost" onClick={disconnectTerminal}>
Disconnect
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => void connectTerminal()}
disabled={connectionState === "connecting"}
>
<Terminal className="mr-1.5 h-3.5 w-3.5" />
{connectionState === "closed" || connectionState === "error" ? "Reconnect" : "Open terminal"}
</Button>
)}
</div>
</div>
<div className="bg-neutral-950 p-2 focus-within:ring-2 focus-within:ring-ring">
<div
ref={terminalElementRef}
data-testid={`custom-image-terminal-screen-${sessionId}`}
aria-label="Custom image browser terminal"
role="application"
tabIndex={0}
onFocus={() => 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"
/>
</div>
{errorMessage ? (
<div className="border-t border-border/60 px-3 py-2 text-xs text-destructive">
{errorMessage}
</div>
) : null}
</div>
);
}
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({
</Button>
</div>
</div>
{session.status === "waiting_for_user" && connectionPayload?.type === "ssh" ? (
<EnvironmentCustomImageBrowserTerminal autoConnect sessionId={session.id} />
) : null}
{session.status === "waiting_for_user" && connectionCommand ? (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md bg-muted/30 px-3 py-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">
<details className="mt-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-foreground">
SSH command fallback
</summary>
<code className="mt-2 block overflow-x-auto whitespace-nowrap text-[11px] leading-5">
{connectionCommand}
</code>
</div>
</details>
) : 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 ? (