Files
PaperClipAI/packages/shared/src/environment-custom-images.test.ts
T
Devin FoleyandPaperclip bcac517f3b 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>
2026-07-03 16:44:21 -07:00

159 lines
5.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE,
redactEnvironmentCustomImageSetupSession,
redactEnvironmentCustomImageTemplate,
} from "./environment-custom-images.js";
import {
environmentCustomImageSetupConnectionSummarySchema,
environmentCustomImageSetupSessionSchema,
environmentCustomImageTemplateSchema,
startEnvironmentCustomImageSetupSessionSchema,
} from "./validators/environment-custom-images.js";
const environmentId = "22222222-2222-4222-8222-222222222222";
const templateId = "33333333-3333-4333-8333-333333333333";
const sessionId = "44444444-4444-4444-8444-444444444444";
describe("environment customImage validators", () => {
it("requires environment scope on templates and setup sessions", () => {
const template = environmentCustomImageTemplateSchema.parse({
id: templateId,
environmentId,
provider: "daytona",
templateKind: "snapshot",
templateRef: "snapshot-ref",
sourceTemplateRef: null,
sourceEnvironmentConfigFingerprint: "sha256:abc",
status: "active",
createdByUserId: "user-1",
createdByAgentId: null,
capturedAt: "2026-06-25T12:00:00.000Z",
lastUsedAt: null,
supersededByTemplateId: null,
metadata: { adapterType: "codex_local" },
createdAt: "2026-06-25T12:00:00.000Z",
updatedAt: "2026-06-25T12:00:00.000Z",
});
expect(template.environmentId).toBe(environmentId);
const session = environmentCustomImageSetupSessionSchema.parse({
id: sessionId,
environmentId,
templateId,
promotedTemplateId: null,
provider: "daytona",
providerLeaseId: "lease-1",
environmentLeaseId: null,
status: "waiting_for_user",
startedByUserId: "user-1",
startedByAgentId: null,
baseTemplateRef: "snapshot-ref",
expiresAt: "2026-06-25T13:00:00.000Z",
finishedAt: null,
failureReason: null,
connectionSummary: {
type: "ssh",
username: "sandbox",
hostRedacted: true,
portRedacted: true,
},
connectionSecretRef: "secret-ref",
metadata: null,
createdAt: "2026-06-25T12:00:00.000Z",
updatedAt: "2026-06-25T12:00:00.000Z",
});
expect(session.environmentId).toBe(environmentId);
});
it("validates setup-session requests and redacted connection summaries", () => {
expect(startEnvironmentCustomImageSetupSessionSchema.parse({ templateId })).toEqual({ templateId });
expect(() => startEnvironmentCustomImageSetupSessionSchema.parse({ ttlSeconds: 30 })).toThrow();
expect(
environmentCustomImageSetupConnectionSummarySchema.parse({
type: "ssh",
username: "sandbox",
}),
).toMatchObject({
type: "ssh",
username: "sandbox",
hostRedacted: true,
portRedacted: true,
});
expect(() => environmentCustomImageSetupConnectionSummarySchema.parse({
type: "ssh",
hostRedacted: false,
})).toThrow();
});
});
describe("environment customImage redaction", () => {
it("redacts template refs and secret-like provider metadata", () => {
const redacted = redactEnvironmentCustomImageTemplate({
templateRef: "daytona-snapshot-secret-ref",
sourceTemplateRef: "base-image-secret-ref",
metadata: {
safeLabel: "codex template",
apiToken: "token-value",
userMetadata: {
safe: "kept",
},
nested: {
host: "203.0.113.10",
safe: "kept",
},
},
});
expect(redacted.templateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE);
expect(redacted.sourceTemplateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE);
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",
},
});
});
it("redacts setup lease and connection material while preserving redaction flags", () => {
const redacted = redactEnvironmentCustomImageSetupSession({
providerLeaseId: "lease-secret",
baseTemplateRef: "snapshot-secret",
connectionSecretRef: "secret-ref",
connectionSummary: {
type: "ssh",
username: "sandbox",
hostRedacted: true,
portRedacted: true,
instructions: "ssh sandbox@203.0.113.10",
},
metadata: {
connectUrl: "https://internal.example.test/session",
},
});
expect(redacted.providerLeaseId).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE);
expect(redacted.baseTemplateRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE);
expect(redacted.connectionSecretRef).toBe(REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE);
expect(redacted.connectionSummary).toEqual({
type: "ssh",
username: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE,
hostRedacted: true,
portRedacted: true,
instructions: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE,
});
expect(redacted.metadata).toEqual({
connectUrl: REDACTED_ENVIRONMENT_CUSTOM_IMAGE_VALUE,
});
});
});