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:
parent
47448721e1
commit
bcac517f3b
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"pino-http": "^10.4.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"sharp": "^0.35.2",
|
||||
"ssh2": "^1.17.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ import detectPort from "detect-port";
|
|||
import { createApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { setupEnvironmentCustomImageTerminalWebSocketServer } from "./realtime/environment-custom-image-terminal-ws.js";
|
||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import {
|
||||
feedbackService,
|
||||
|
|
@ -700,6 +701,9 @@ export async function startServer(): Promise<StartedServer> {
|
|||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify(runtimeApiCandidates);
|
||||
process.env.PAPERCLIP_API_URL = configuredApiUrl;
|
||||
|
||||
setupEnvironmentCustomImageTerminalWebSocketServer(server, db as any, {
|
||||
pluginWorkerManager,
|
||||
});
|
||||
setupLiveEventsWebSocketServer(server, db as any, {
|
||||
deploymentMode: config.deploymentMode,
|
||||
resolveSessionFromHeaders,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue