Files
PaperClipAI/server/src/services/environment-execution-target.ts
Nicky LeachandPaperclip 7083c275c8 refactor(sandbox): retire the dead noProfile flag from the exec path (#10461)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The sandbox exec path starts agent commands and passes runtime
options to the server and plugin layers
> - This path kept a noProfile flag after the exec wrappers stopped
sourcing a login profile
> - The flag no longer changed behavior, so it left dead API surface in
the protocol and runtime helpers
> - This pull request removes that dead flag from the plugin protocol,
the server drivers, and the managed-runtime helpers
> - It also updates the tests and points the agent runtime README at the
sandbox requirements file
> - The benefit is a smaller and clearer exec-path contract with no
behavior change

## Linked Issues or Issue Description

- No public GitHub issue exists.

### What happened?

The sandbox exec path kept a `noProfile` field after the exec wrappers
stopped sourcing a login profile.

### Expected behavior

The plugin protocol, server drivers, and managed-runtime helpers should
not expose or forward a dead field.

### Steps to reproduce

1. Run a managed-runtime command through the sandbox exec path.
2. Inspect the protocol payload and runtime helper inputs.
3. Observe that `noProfile` is present even though it no longer changes
behavior.

### Paperclip version or commit

`60c7da86fc7a6c1dbf37bbcd86e25ecaaff01607`

### Deployment mode

Built from source (pnpm dev / pnpm build)

### Additional context

This pull request removes the dead field, updates the affected tests,
and updates the README note for the sandbox profile path.

## What Changed

- Removed noProfile from the plugin protocol and the server exec-path
call sites.
- Updated the managed-runtime helpers to use the narrower exec-path
contract.
- Updated the affected tests and added the README pointer to
SANDBOX-REQUIREMENTS.md.

## Verification

- `git grep -n "noProfile" -- packages/ server/` returns zero matches.
- `tsc --noEmit` passed for `@paperclipai/adapter-utils`,
`@paperclipai/plugin-sdk`, and `@paperclipai/server`.
- `command-managed-runtime.test.ts` passed: 22/22.
- `environment-runtime.test.ts` passed: 24/24.

## Risks

- Low risk. The flag was already a no-op.
- A hidden external caller may still send the removed field.

## Model Used

- OpenAI GPT-5, tool-enabled.

## 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-29 14:21:57 -07:00

204 lines
8.0 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import type { Environment, EnvironmentLease } from "@paperclipai/shared";
import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared";
import {
adapterExecutionTargetToRemoteSpec,
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import { parseObject } from "../adapters/utils.js";
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
export const DEFAULT_SANDBOX_REMOTE_CWD = "/tmp";
export async function resolveEnvironmentExecutionTarget(input: {
db: Db;
companyId: string;
adapterType: string;
environment: {
id?: string;
driver: string;
config: Record<string, unknown> | null;
};
leaseId?: string | null;
leaseMetadata: Record<string, unknown> | null;
lease?: EnvironmentLease | null;
environmentRuntime?: EnvironmentRuntimeService | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
return {
kind: "local",
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
};
}
if (input.environment.driver === "sandbox") {
// Keep this gate in lockstep with the shared capability metadata that the
// environment selector and capabilities API expose; a drift here lets the
// UI offer environments the runtime then refuses.
if (!adapterSupportsRemoteManagedEnvironments(input.adapterType)) {
return null;
}
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
id: input.environment.id,
driver: input.environment.driver as "sandbox",
config: parseObject(input.environment.config),
});
if (parsed.driver !== "sandbox") {
return null;
}
const remoteCwd =
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
? input.leaseMetadata.remoteCwd.trim()
: DEFAULT_SANDBOX_REMOTE_CWD;
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null;
const shellCommand =
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
? input.leaseMetadata.shellCommand
: null;
// Per-lease-runner cumulative counters for startup-step attribution (Open
// Q1). Closed over by the `runner.execute` seam below and read back as
// deltas by `measureStartupStep`.
let execCount = 0;
let providerExecMs = 0;
let providerGetMs = 0;
const accumulateProviderDurations = (metadata: Record<string, unknown> | undefined): void => {
if (!metadata) return;
const exec = metadata.durationMs;
const get = metadata.getDurationMs;
if (typeof exec === "number" && Number.isFinite(exec)) providerExecMs += exec;
if (typeof get === "number" && Number.isFinite(get)) providerGetMs += get;
};
return {
kind: "remote",
transport: "sandbox",
providerKey: parsed.config.provider,
shellCommand,
remoteCwd,
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
timeoutMs,
// Run-log streaming defaults ON for sandbox environments so agent CLI
// output reaches the UI mid-run; `streamRunLogs: false` is an explicit
// opt-out back to batch-at-end delivery.
streamRunLogs: parsed.config.streamRunLogs !== false,
runner: input.environmentRuntime && input.lease
? {
// Provider-backed sandbox RPCs do not surface bounded mid-stream
// progress for a single stdin upload, so keep the capability disabled
// here. The client falls back to the chunked upload path when this is
// false.
supportsSingleStreamStdinProgress: false,
// Round-trip counter + provider-duration accumulators on the single
// host→sandbox exec seam (Open Q1). `measureStartupStep` reads the
// per-step delta of each via the `() => number` closures below. The
// provider durations ride the exec result's free-form `metadata`
// (set by the Daytona plugin), so no protocol/schema change is
// needed and providers that omit them simply accumulate nothing.
execCount: () => execCount,
providerExecMs: () => providerExecMs,
providerGetMs: () => providerGetMs,
execute: async (commandInput) => {
execCount += 1;
const startedAt = new Date().toISOString();
const result = await input.environmentRuntime!.execute({
environment: input.environment as Environment,
lease: input.lease!,
command: commandInput.command,
args: commandInput.args,
cwd: commandInput.cwd ?? remoteCwd,
env: commandInput.env,
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
});
accumulateProviderDurations(result.metadata);
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
return {
exitCode: result.exitCode,
signal: result.signal ?? null,
timedOut: result.timedOut,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
},
// Expose the native file-sync capability only when the provider's
// worker advertises BOTH sync verbs; otherwise leave syncIn/syncOut
// undefined so the orchestrator keeps the byte-identical base64 path.
...(input.environmentRuntime.supportsSync({
environment: input.environment as Environment,
lease: input.lease,
})
? {
syncIn: (operations) =>
input.environmentRuntime!.syncIn({
environment: input.environment as Environment,
lease: input.lease!,
operations,
}),
syncOut: (operations) =>
input.environmentRuntime!.syncOut({
environment: input.environment as Environment,
lease: input.lease!,
operations,
}),
}
: {}),
}
: undefined,
};
}
if (
!adapterSupportsRemoteManagedEnvironments(input.adapterType) ||
input.environment.driver !== "ssh"
) {
return null;
}
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
id: input.environment.id,
driver: input.environment.driver as "ssh",
config: parseObject(input.environment.config),
});
if (parsed.driver !== "ssh") {
return null;
}
const remoteCwd =
typeof input.leaseMetadata?.remoteCwd === "string" && input.leaseMetadata.remoteCwd.trim().length > 0
? input.leaseMetadata.remoteCwd.trim()
: parsed.config.remoteWorkspacePath;
return {
kind: "remote",
transport: "ssh",
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
remoteCwd,
spec: {
host: parsed.config.host,
port: parsed.config.port,
username: parsed.config.username,
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
privateKey: parsed.config.privateKey,
knownHosts: parsed.config.knownHosts,
strictHostKeyChecking: parsed.config.strictHostKeyChecking,
remoteCwd,
},
};
}
export async function resolveEnvironmentExecutionTransport(
input: Parameters<typeof resolveEnvironmentExecutionTarget>[0],
): Promise<Record<string, unknown> | null> {
return adapterExecutionTargetToRemoteSpec(await resolveEnvironmentExecutionTarget(input)) as Record<string, unknown> | null;
}