Files
PaperClipAI/server/src/services/workspace-realization.ts
T
Nicky LeachandPaperclip a14e51d592 refactor(environment): classify environment capabilities from static driver definitions (#12045)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Environment runtime drivers provide workspace, lease, and custom
image behavior
> - Runtime code used driver identity checks and several
capability-specific members
> - These checks spread capability rules across the runtime and made new
drivers harder to verify
> - This pull request adds one general capability classifier and one
static driver support table
> - The benefit is one fail-closed capability model that keeps current
behavior and supports future drivers

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Environment runtime capability checks for workspace realization, custom
images, lease capabilities, and duplex authorization.

**Subsystem affected**

Cross-cutting (multiple of the above)

**Current behavior**

The runtime selects several capability paths from driver identity and
separate capability members. Custom image gates also trust provider
declarations without checking every matching live worker method.

**Proposed behavior**

The runtime uses one general capability classifier and one static
support table. Custom image gates require both the provider declaration
and every matching live worker method. The public capability names
remain unchanged.

**Reason and benefit**

The change keeps capability rules in one place. It removes identity
conditions from runtime consumers and makes unsupported drivers fail
closed.

**Breaking changes**

None. The public names sandboxCapabilities, sandboxProviders, and
EffectiveSandboxCapabilities remain available.

## What Changed

- Add classifyEnvironmentCapabilities and static support definitions for
all four driver families.
- Add resolveCapabilities to every environment runtime driver.
- Move driver traits into environment-driver-traits.ts and migrate
runtime consumers.
- Require provider declarations and matching live worker methods for all
custom image gates.
- Migrate duplex authorization to the general resolver and remove the
dead sandbox-only member.
- Delete the unused resolveEffectiveSandboxCapabilities wrapper and
update its test.

## Verification

- pnpm --filter @paperclipai/server typecheck
- pnpm exec vitest run
server/src/__tests__/environment-capability-contract.test.ts
server/src/__tests__/environment-runtime.test.ts — 92 tests pass
- pnpm exec vitest run
server/src/__tests__/environment-driver-traits.test.ts
server/src/__tests__/general-capability-classifier.test.ts — 12 tests
pass
- pnpm exec vitest run
server/src/__tests__/environment-custom-images-service.test.ts
server/src/__tests__/environment-execution-target-capabilities.test.ts
server/src/__tests__/environment-execution-target-duplex.test.ts
server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts
— 66 tests pass

## Risks

The main risk is a capability gate that denies a valid driver or permits
an invalid driver. The static support matrix, live worker method checks,
and regression tests reduce this risk. No database, public API, or
published type name changes.

## Model Used

OpenAI Codex, GPT-5, with tool use and code execution. The deployment
does not provide a separate context-window value.

## 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 (for example, docs/... or
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-08-23 21:24:59 -07:00

329 lines
13 KiB
TypeScript

import type {
Environment,
EnvironmentLease,
ExecutionWorkspaceConfig,
WorkspaceRealizationRecord,
WorkspaceRealizationRequest,
} from "@paperclipai/shared";
import type { RealizedExecutionWorkspace } from "./workspace-runtime.js";
import { ENVIRONMENT_DRIVER_TRAITS, getEnvironmentDriverTraits } from "./environment-driver-traits.js";
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.map(readString).filter((entry): entry is string => entry !== null)
: [];
}
function readPathAliases(value: unknown): Array<{ path: string; target: string }> {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const parsed = parseObject(entry);
const aliasPath = readString(parsed.path);
const target = readString(parsed.target);
return aliasPath && target ? [{ path: aliasPath, target }] : [];
});
}
// Read the additional referenced (mentioned) project sources. Legacy payloads omit the field, so
// this defaults to an empty array. Each source needs a localPath; entries without one are dropped.
function readAdditionalSources(
value: unknown,
): NonNullable<WorkspaceRealizationRequest["additionalSources"]> {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const parsed = parseObject(entry);
const localPath = readString(parsed.localPath);
if (!localPath) return [];
return [
{
localPath,
projectId: readString(parsed.projectId),
projectWorkspaceId: readString(parsed.projectWorkspaceId),
repoUrl: readString(parsed.repoUrl),
repoRef: readString(parsed.repoRef),
},
];
});
}
export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null {
const parsed = parseObject(value);
if (parsed.version !== 1) return null;
const source = parseObject(parsed.source);
const runtimeOverlay = parseObject(parsed.runtimeOverlay);
const localPath = readString(source.localPath);
const companyId = readString(parsed.companyId);
const environmentId = readString(parsed.environmentId);
const heartbeatRunId = readString(parsed.heartbeatRunId);
const adapterType = readString(parsed.adapterType);
if (!localPath || !companyId || !environmentId || !heartbeatRunId || !adapterType) return null;
return {
version: 1,
adapterType,
companyId,
environmentId,
executionWorkspaceId: readString(parsed.executionWorkspaceId),
issueId: readString(parsed.issueId),
heartbeatRunId,
requestedMode: readString(parsed.requestedMode),
source: {
kind:
source.kind === "task_session" || source.kind === "agent_home"
? source.kind
: "project_primary",
localPath,
projectId: readString(source.projectId),
projectWorkspaceId: readString(source.projectWorkspaceId),
repoUrl: readString(source.repoUrl),
repoRef: readString(source.repoRef),
strategy: source.strategy === "git_worktree" ? "git_worktree" : "project_primary",
branchName: readString(source.branchName),
worktreePath: readString(source.worktreePath),
},
additionalSources: readAdditionalSources(parsed.additionalSources),
runtimeOverlay: {
provisionCommand: readString(runtimeOverlay.provisionCommand),
runtimeProvisionCommand: readString(runtimeOverlay.runtimeProvisionCommand),
teardownCommand: readString(runtimeOverlay.teardownCommand),
cleanupCommand: readString(runtimeOverlay.cleanupCommand),
workspaceRuntime: Object.keys(parseObject(runtimeOverlay.workspaceRuntime)).length > 0
? parseObject(runtimeOverlay.workspaceRuntime)
: null,
},
};
}
export function buildWorkspaceRealizationRequest(input: {
adapterType: string;
companyId: string;
environmentId: string;
executionWorkspaceId: string | null;
issueId: string | null;
heartbeatRunId: string;
requestedMode: string | null;
workspace: RealizedExecutionWorkspace;
workspaceConfig: ExecutionWorkspaceConfig | null;
}): WorkspaceRealizationRequest {
return {
version: 1,
adapterType: input.adapterType,
companyId: input.companyId,
environmentId: input.environmentId,
executionWorkspaceId: input.executionWorkspaceId,
issueId: input.issueId,
heartbeatRunId: input.heartbeatRunId,
requestedMode: input.requestedMode,
source: {
kind: input.workspace.source,
localPath: input.workspace.cwd,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,
repoUrl: input.workspace.repoUrl,
repoRef: input.workspace.repoRef,
strategy: input.workspace.strategy,
branchName: input.workspace.branchName,
worktreePath: input.workspace.worktreePath,
},
// The additional (referenced) sources carry the read-only referenced-project workspaces. Run
// preparation resolves them for a local execution target only and exposes each local path to
// the agent through the workspace-hints channel (`PAPERCLIP_WORKSPACES_JSON`). A remote target
// never receives a referenced source: run preparation skips referenced-project resolution on a
// remote target, so this array is empty there. The `sync` block below therefore realizes only
// the anchor source; a remote-transport sync of the referenced trees is not implemented yet.
additionalSources: (input.workspace.additionalWorkspaces ?? []).map((additional) => ({
localPath: additional.cwd,
projectId: additional.projectId,
projectWorkspaceId: additional.workspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
runtimeOverlay: {
provisionCommand: input.workspaceConfig?.provisionCommand ?? null,
runtimeProvisionCommand: input.workspaceConfig?.runtimeProvisionCommand ?? null,
teardownCommand: input.workspaceConfig?.teardownCommand ?? null,
cleanupCommand: input.workspaceConfig?.cleanupCommand ?? null,
workspaceRuntime: input.workspaceConfig?.workspaceRuntime ?? null,
},
};
}
export function buildWorkspaceRealizationRecord(input: {
environment: Environment;
lease: EnvironmentLease;
request: WorkspaceRealizationRequest;
realizedCwd?: string | null;
providerMetadata?: Record<string, unknown> | null;
}): WorkspaceRealizationRecord {
const leaseMetadata = input.lease.metadata ?? {};
const providerMetadata = input.providerMetadata ?? {};
// An unknown or absent driver reads the "local" row, same as today's fallback.
const traits = getEnvironmentDriverTraits(input.environment.driver) ?? ENVIRONMENT_DRIVER_TRAITS.local;
const transport = traits.driver;
const remotePath =
readString(providerMetadata.remoteCwd) ??
readString(leaseMetadata.remoteCwd) ??
readString(providerMetadata.remotePath) ??
null;
const host = readString(leaseMetadata.host);
const port = readNumber(leaseMetadata.port);
const username = readString(leaseMetadata.username);
const sandboxId = readString(leaseMetadata.sandboxId) ?? readString(providerMetadata.sandboxId);
const realizationMetadata = {
...parseObject(leaseMetadata.workspaceRealization),
...parseObject(providerMetadata.workspaceRealization),
...providerMetadata,
};
const mode = realizationMetadata.mode === "in_place" || realizationMetadata.realizationMode === "in_place"
? "in_place" as const
: "copy" as const;
const authoritativeRoot =
readString(realizationMetadata.authoritativeRoot) ??
(mode === "in_place" ? remotePath : null) ??
input.request.source.localPath;
const pathAliases = readPathAliases(realizationMetadata.pathAliases ?? realizationMetadata.workspaceAliases);
const outboundRestorePaths = readStringArray(realizationMetadata.outboundRestorePaths);
const provider =
input.lease.provider ??
(transport === "ssh" ? "ssh" : transport === "local" ? "local" : null);
const localPath = input.request.source.localPath;
const summary =
transport === "local"
? `Local workspace realized at ${localPath}.`
: transport === "ssh"
? `SSH workspace realized at ${username ?? "user"}@${host ?? "host"}:${port ?? 22}:${remotePath ?? input.request.source.localPath}.`
: transport === "sandbox"
? `Sandbox workspace realized at ${remotePath ?? "/"}${sandboxId ? ` in ${sandboxId}` : ""}.`
: `Plugin workspace realized at ${input.realizedCwd ?? remotePath ?? localPath}.`;
return {
version: 1,
mode,
authoritativeRoot,
pathAliases,
outboundRestorePaths,
provider,
environmentId: input.environment.id,
leaseId: input.lease.id,
providerLeaseId: input.lease.providerLeaseId,
local: {
path: localPath,
source: input.request.source.kind,
strategy: input.request.source.strategy,
projectId: input.request.source.projectId,
projectWorkspaceId: input.request.source.projectWorkspaceId,
repoUrl: input.request.source.repoUrl,
repoRef: input.request.source.repoRef,
branchName: input.request.source.branchName,
worktreePath: input.request.source.worktreePath,
},
additional: (input.request.additionalSources ?? []).map((additional) => ({
path: additional.localPath,
projectId: additional.projectId,
projectWorkspaceId: additional.projectWorkspaceId,
repoUrl: additional.repoUrl,
repoRef: additional.repoRef,
})),
remote: {
path: remotePath,
...(host ? { host } : {}),
...(port ? { port } : {}),
...(username ? { username } : {}),
...(sandboxId ? { sandboxId } : {}),
},
bootstrap: {
command: input.request.runtimeOverlay.provisionCommand,
},
rebuild: {
executionWorkspaceId: input.request.executionWorkspaceId,
mode: input.request.requestedMode,
repoUrl: input.request.source.repoUrl,
repoRef: input.request.source.repoRef,
localPath,
remotePath,
providerLeaseId: input.lease.providerLeaseId,
metadata: {
source: input.request.source,
runtimeOverlay: input.request.runtimeOverlay,
environmentDriver: input.environment.driver,
provider,
providerMetadata,
},
},
summary,
};
}
/**
* Build the workspace-realization record from the run request. The server owns the record;
* a driver realize handler (built-in or plugin) returns only a realized cwd and provider
* metadata. Every `realizeWorkspace` exit must route through this helper, so the record carries
* the referenced (mentioned) project sources in `additional`. The adapter reads `additional` to
* stage each referenced tree into the target; a realize exit that returns a raw provider result
* without this helper drops the mentioned projects.
*/
export function buildWorkspaceRealizationRecordFromDriverInput(input: {
environment: Environment;
lease: EnvironmentLease;
workspace: {
localPath?: string;
remotePath?: string;
mode?: string;
metadata?: Record<string, unknown>;
};
cwd?: string | null;
providerMetadata?: Record<string, unknown> | null;
}): WorkspaceRealizationRecord {
const request =
readWorkspaceRealizationRequest(input.workspace.metadata?.workspaceRealizationRequest) ??
readWorkspaceRealizationRequest(input.workspace.metadata?.request) ??
buildWorkspaceRealizationRequest({
adapterType: "unknown",
companyId: input.lease.companyId,
environmentId: input.environment.id,
executionWorkspaceId: input.lease.executionWorkspaceId,
issueId: input.lease.issueId,
heartbeatRunId: input.lease.heartbeatRunId ?? "unknown",
requestedMode: input.workspace.mode ?? null,
workspace: {
baseCwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
source: "task_session",
projectId: null,
workspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
cwd: input.workspace.localPath ?? input.cwd ?? input.workspace.remotePath ?? "/",
branchName: null,
worktreePath: null,
warnings: [],
created: false,
branchCreatedByRuntime: false,
},
workspaceConfig: null,
});
return buildWorkspaceRealizationRecord({
environment: input.environment,
lease: input.lease,
request,
realizedCwd: input.cwd ?? null,
providerMetadata: input.providerMetadata,
});
}