mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent work runs in sandboxes that provider plugins supply > - Operators can choose a provider to run agent work > - CreateOS adds another provider with workspace-preserving pause and resume > - This pull request adds a CreateOS provider plugin > - The benefit is that operators can preserve a workspace between runs without keeping its compute active ## Linked Issues or Issue Description Refs #13203 and the earlier closed #13096. This continues the CreateOS contribution from @bhautikchudasama and @ashwaq06. The branch preserves the original implementation commit. Thank you to both contributors. When squash-merging, preserve the original author's credit in the squash commit body: ```text Co-Authored-By: bhautikchudasama <BhautikChudasama@users.noreply.github.com> ``` The original fork rejects maintainer pushes. This branch includes the merge-conflict resolution and review fixes. The request is described below using `adapter_request.yml`. **Agent or provider** CreateOS sandbox API (https://api.sb.createos.sh). **Why this adapter is useful** CreateOS can pause a sandbox and resume it by ID. The workspace survives the pause. This adds a reusable-lease option to the existing sandbox provider system. **How the agent is invoked** Build and install the local plugin as described in its README. Open Instance Settings, then Environments. Select the `createos` driver. Supply an API key and shape. The driver then supplies sandbox leases for agent runs. **Are you willing to implement it?** Yes. This pull request is the implementation. ## What Changed - Adds the `createos` sandbox provider under `packages/plugins/sandbox-providers/createos`. - Calls the CreateOS HTTP API directly. The package adds no vendor SDK. - Implements the environment lifecycle hooks, incremental process output, and binary workspace sync. - Registers the optional bundled provider and its trusted host credential fallback. The fallback is limited to the official API origin; custom endpoints require an explicit key. - Lists the package in the release manifest with `publishFromCi: false` until its first npm publish is bootstrapped. - Waits through delayed pause/resume state updates without duplicate action requests. - Cancels queued API requests promptly while preserving request spacing. - Uses direct CLI invocation in the setup guide so paths and IDs are passed without an extra shell expansion. - Includes current master and retains its existing Git-subfolder containment fix. ## Demo Fresh setup and a run against a CreateOS sandbox. https://github.com/user-attachments/assets/e71b9e06-c006-4fb9-b847-52dfd68f6110 https://github.com/user-attachments/assets/43b5ac75-66bd-4f76-8563-67e4c7759084 ## Verification All 25 jobs in [CI run 34884260542](https://github.com/paperclipai/paperclip/actions/runs/34884260542) passed at commit `f8d0997677024b784fdadf9d44a84c01cb4e813c`, including typecheck, build, native runner verification, server and workspace tests, browser tests, and the canary release dry run. Greptile reviewed the same commit at 5/5 with no unresolved review threads. GitHub reports no merge conflicts. The remaining merge gate is code-owner approval for the new `package.json`, as required by `.github/CODEOWNERS` and the `master` ruleset. Reviewers have been requested automatically. Local checks passed: - Provider: `pnpm typecheck`, `pnpm test` (52 passed, one live smoke skipped), and `pnpm build`. - Host: focused credential and bundled-plugin tests (17 passed), plus CLI invocation safety (39 passed). - Release: package manifest check and release policy tests (18 passed). The full local `pnpm test:run` attempt caught the README command issue; its focused rerun now passes. The full local run stopped after its general-server group: 7,804 tests passed, with unrelated embedded PostgreSQL startup failures and 10 failures in unchanged runtime-skill-cache tests (`EACCES` on directory rename on macOS). It did not reach the later test groups. Local `pnpm -r typecheck` and `pnpm build` reach the runner package and stop because this machine has no Rust/Cargo installation. The corresponding CI checks passed on provisioned runners, as linked above. The live CreateOS smoke requires explicit provider credentials and was not run during this review. It is available with `CREATEOS_LIVE_TEST=1 pnpm test` in the provider directory. The author supplied the demo links above. ## Risks The provider is opt-in and is not installed by default. It is available through a local-path install or explicit image inclusion. npm publication remains disabled until a maintainer bootstraps the package and enables publishing. Sandbox creation has no idempotency key. An ambiguous create response can leave a resource that requires provider-account inspection. Process tracking is in memory; durable lease recovery belongs to the host. The provider does not advertise guaranteed expiry, interactive login, snapshots, duplex channels, or ingress. Live native-runner qualification remains outside this PR's tested claims. ## Model Used Original provider implementation: human-authored by @bhautikchudasama, as reported in #13203. The original description reports Claude Opus 5 assistance. Review and follow-up fixes: OpenAI GPT-6 via Codex, with code review, editing, and tool execution. The precise runtime model variant and context-window size are not exposed in this session. ## 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 (focused checks; full-suite environment limits documented above) - [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: bhautikchudasama <bhautikrchudasama@gmail.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { StringDecoder } from "node:string_decoder";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import type { PluginEnvironmentExecuteParams, PluginEnvironmentExecuteResult } from "@paperclipai/plugin-sdk";
|
|
import { CreateosApiError, CreateosClient, identifier, object } from "./client.js";
|
|
|
|
const MAX_LINE_BYTES = 1_048_576;
|
|
const MAX_CAPTURE_CHARS = 4_194_304;
|
|
|
|
export class CreateosCleanupError extends Error {}
|
|
|
|
export function shellQuote(value: string): string {
|
|
if (value.includes("\0")) throw new Error("Sandbox command values cannot contain NUL.");
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function commandScript(params: PluginEnvironmentExecuteParams, stdinPath: string | null): string {
|
|
if (!params.command) throw new Error("A sandbox command is required.");
|
|
const env = Object.entries(params.env ?? {}).map(([key, value]) => {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== "string") {
|
|
throw new Error("Invalid sandbox environment variable.");
|
|
}
|
|
return `${key}=${shellQuote(value)}`;
|
|
});
|
|
const command = [params.command, ...(params.args ?? [])].map(shellQuote).join(" ");
|
|
return [
|
|
params.cwd ? `cd -- ${shellQuote(params.cwd)} || exit` : "",
|
|
`exec env ${env.join(" ")} ${command}${stdinPath ? ` < ${shellQuote(stdinPath)}` : ""}`,
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
async function* events(response: Response): AsyncGenerator<Record<string, unknown>> {
|
|
if (!response.body) throw new Error("CreateOS returned an empty process stream.");
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let pending = "";
|
|
try {
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
pending += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
|
let newline: number;
|
|
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
const line = pending.slice(0, newline);
|
|
pending = pending.slice(newline + 1);
|
|
if (line.length > MAX_LINE_BYTES) throw new Error("CreateOS process frame is too large.");
|
|
if (line.trim()) yield parseEvent(line);
|
|
}
|
|
if (pending.length > MAX_LINE_BYTES) throw new Error("CreateOS process frame is too large.");
|
|
if (done) {
|
|
if (pending.trim()) yield parseEvent(pending);
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
await reader.cancel().catch(() => undefined);
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
function parseEvent(line: string): Record<string, unknown> {
|
|
try { return object(JSON.parse(line)); }
|
|
catch { throw new Error("CreateOS returned an invalid process frame."); }
|
|
}
|
|
|
|
class Output {
|
|
stdout = "";
|
|
stderr = "";
|
|
truncated = false;
|
|
private decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
constructor(private log: (stream: "stdout" | "stderr", text: string) => void) {}
|
|
|
|
private append(stream: "stdout" | "stderr", text: string) {
|
|
if (!text) return;
|
|
this.log(stream, text);
|
|
const combined = this[stream] + text;
|
|
if (combined.length > MAX_CAPTURE_CHARS) this.truncated = true;
|
|
this[stream] = combined.slice(-MAX_CAPTURE_CHARS);
|
|
}
|
|
|
|
write(stream: "stdout" | "stderr", bytes: Buffer) {
|
|
this.append(stream, this.decoders[stream].write(bytes));
|
|
}
|
|
|
|
finish() {
|
|
for (const stream of ["stdout", "stderr"] as const) this.append(stream, this.decoders[stream].end());
|
|
}
|
|
}
|
|
|
|
// A broken output connection does not stop a managed process. Always reconnect
|
|
// to the SAME process, never retry its creation, and explicitly terminate its
|
|
// tree on timeout/failure. Cleanup has its own deadline, independent of execute.
|
|
export async function execute(
|
|
client: CreateosClient,
|
|
params: PluginEnvironmentExecuteParams,
|
|
signal: AbortSignal,
|
|
log: (stream: "stdout" | "stderr", text: string) => void = () => {},
|
|
): Promise<PluginEnvironmentExecuteResult> {
|
|
const id = identifier(params.lease.providerLeaseId);
|
|
const stdinPath = params.stdin != null ? `/tmp/paperclip-stdin-${randomUUID()}` : null;
|
|
const script = commandScript(params, stdinPath);
|
|
const output = new Output(log);
|
|
let processId: string | null = null;
|
|
let creationMayHaveSucceeded = false;
|
|
let completed = false;
|
|
let staged = false;
|
|
let cursor = 0;
|
|
const base = `/sandboxes/${id}/processes`;
|
|
try {
|
|
if (stdinPath) {
|
|
staged = true;
|
|
await client.upload(id, stdinPath, params.stdin!, signal);
|
|
}
|
|
let created: Record<string, unknown>;
|
|
creationMayHaveSucceeded = true;
|
|
try {
|
|
created = await client.json(base, "POST", {
|
|
cmd: "/bin/bash", args: ["-lc", script],
|
|
...(params.cwd ? { cwd: params.cwd } : {}),
|
|
// CreateOS API overrides are limited to keys declared at sandbox
|
|
// creation. Per-command variables are already safely quoted in the
|
|
// command's `env` invocation, including on a reused sandbox.
|
|
}, signal);
|
|
} catch (error) {
|
|
if (error instanceof CreateosApiError && error.status < 500) creationMayHaveSucceeded = false;
|
|
throw error;
|
|
}
|
|
processId = identifier(created.process_id);
|
|
// No interactive stdin for ordinary commands; any supplied input comes
|
|
// from the staged file, avoiding a write-vs-fast-exit race.
|
|
try { await client.json(`${base}/${processId}/stdin/close`, "POST", undefined, signal); }
|
|
catch (error) {
|
|
if (!(error instanceof CreateosApiError && error.status === 409)) throw error;
|
|
}
|
|
let reconnects = 0;
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
let response: Response;
|
|
try {
|
|
response = await client.request(`${base}/${processId}/connect?after=${cursor}`, { signal });
|
|
} catch (error) {
|
|
if (error instanceof CreateosApiError && error.status === 410) {
|
|
throw new Error("CreateOS process output was evicted before it could be read.");
|
|
}
|
|
if (signal.aborted || (error instanceof CreateosApiError && error.status < 500 && error.status !== 429)) throw error;
|
|
if (++reconnects > 3) throw new Error("CreateOS process output connection failed.");
|
|
await delay(250, undefined, { signal });
|
|
continue;
|
|
}
|
|
try {
|
|
for await (const event of events(response)) {
|
|
if (event.type === "heartbeat") continue;
|
|
if (event.type === "data") {
|
|
const seq = event.seq;
|
|
if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 1) throw new Error("CreateOS process sequence is invalid.");
|
|
if (seq <= cursor) continue;
|
|
if (seq !== cursor + 1) throw new Error("CreateOS process output has a sequence gap.");
|
|
if ((event.stream !== "stdout" && event.stream !== "stderr") ||
|
|
typeof event.data_base64 !== "string" ||
|
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(event.data_base64)) {
|
|
throw new Error("CreateOS process output is invalid.");
|
|
}
|
|
output.write(event.stream, Buffer.from(event.data_base64, "base64"));
|
|
cursor = seq;
|
|
} else if (event.type === "exit") {
|
|
const exitCode = event.exit_code;
|
|
const exitSignal = event.signal;
|
|
if (!(typeof exitCode === "number" && Number.isInteger(exitCode)) &&
|
|
!(typeof exitSignal === "string" && /^SIG[A-Z0-9]+$/.test(exitSignal))) {
|
|
throw new Error("CreateOS process exit status is missing.");
|
|
}
|
|
completed = true;
|
|
output.finish();
|
|
return {
|
|
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
signal: typeof exitSignal === "string" ? exitSignal : null,
|
|
timedOut: false, stdout: output.stdout, stderr: output.stderr,
|
|
metadata: { processId, outputTruncated: output.truncated },
|
|
};
|
|
} else if (event.type === "error") {
|
|
throw new Error(event.error === "output_offset_expired"
|
|
? "CreateOS process output was evicted before it could be read."
|
|
: "CreateOS process stream reported an error.");
|
|
} else {
|
|
throw new Error("CreateOS returned an unknown process event.");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Network read failures can resume from the last accepted sequence.
|
|
// Protocol errors must fail closed rather than reconnect past bad data.
|
|
if (!(error instanceof TypeError) || signal.aborted) throw error;
|
|
}
|
|
if (++reconnects > 3) throw new Error("CreateOS process stream ended without an exit status.");
|
|
await delay(250, undefined, { signal });
|
|
}
|
|
} catch (error) {
|
|
if (creationMayHaveSucceeded && !processId) {
|
|
throw new CreateosCleanupError("CreateOS process creation could not be confirmed; destroy the lease before reusing it.");
|
|
}
|
|
if (signal.aborted && signal.reason?.name === "TimeoutError") {
|
|
output.finish();
|
|
return {
|
|
exitCode: null, timedOut: true, stdout: output.stdout, stderr: output.stderr,
|
|
metadata: { processId, outputTruncated: output.truncated },
|
|
};
|
|
}
|
|
if (signal.aborted) throw new Error("CreateOS command was cancelled.");
|
|
throw error;
|
|
} finally {
|
|
const cleanupSignal = AbortSignal.timeout(client.config.timeoutMs);
|
|
// Do not hide a cleanup failure: the host must know containment is unproven.
|
|
try {
|
|
if (processId && !completed) {
|
|
try {
|
|
const termination = await client.json(`${base}/${processId}?grace_ms=1000`, "DELETE", undefined, cleanupSignal);
|
|
if (termination.tree_exited !== true) throw new Error("Process tree has not exited.");
|
|
}
|
|
catch (error) { if (!(error instanceof CreateosApiError && error.status === 404)) throw new CreateosCleanupError("CreateOS command cleanup failed; process termination is unconfirmed."); }
|
|
}
|
|
} finally {
|
|
if (staged && stdinPath) {
|
|
// /files has no delete verb. /exec supplies a bounded, one-shot removal
|
|
// after the managed process finishes, without retaining another record.
|
|
await client.json(`/sandboxes/${id}/exec`, "POST", {
|
|
cmd: "/bin/rm", args: ["-f", "--", stdinPath],
|
|
}, cleanupSignal).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
}
|