mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## 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>
263 lines
9.9 KiB
TypeScript
263 lines
9.9 KiB
TypeScript
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"]);
|
|
});
|
|
});
|