mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 02:17:27 +02:00
## 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>
329 lines
13 KiB
TypeScript
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,
|
|
});
|
|
}
|