Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencomputer/cli",
"version": "0.6.5",
"version": "0.6.6",
"description": "Build, test, deploy, and share OpenComputer agents as code.",
"type": "module",
"bin": {
Expand Down
11 changes: 11 additions & 0 deletions cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export interface ManagedAgentDeployment {
projectDeploymentId?: string;
localAgentId?: string;
createdAt: string;
status?: "pending" | "ready" | "failed";
stage?: "validating" | "uploading" | "building" | "snapshotting" | "verifying" | "ready";
environmentDigest?: string;
}

export interface ManagedAgentEvent {
Expand Down Expand Up @@ -561,6 +564,14 @@ export class OpenComputerClient {
contentType: string;
body: string;
};
environmentSource?: {
digest: string;
size: number;
contentType: string;
body: string;
baseImage: string;
architecture: "linux/arm64";
};
}) {
return this.request<ManagedAgentDeployment>(
"/api/managed-agents/deployments",
Expand Down
13 changes: 13 additions & 0 deletions cli/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,18 @@ async function registerBuiltDeployment(
contentType: "application/vnd.opencomputer.agent+json",
body: built.body.toString("utf8"),
},
...(built.environment
? {
environmentSource: {
digest: built.environment.digest,
size: built.environment.size,
contentType: built.environment.contentType,
body: built.environment.body.toString("utf8"),
baseImage: built.environment.baseImage,
architecture: built.environment.architecture,
},
}
: {}),
});
return { built, deployment };
}
Expand Down Expand Up @@ -307,6 +319,7 @@ export async function publishProjectDeployment(
agents: builtAgents.map(({ source, built }) => ({
id: source.localId,
artifact: built.digest,
environment: built.environment?.digest,
})),
}),
)
Expand Down
96 changes: 96 additions & 0 deletions cli/src/environment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import test from "node:test";
import {
buildAgentEnvironment,
validateAgentDockerfile,
} from "./environment.js";

const runtime = "registry.opencomputer.dev/serverless-agent:0.6.5";

test("accepts a versioned OpenComputer runtime as the final stage", () => {
assert.deepEqual(validateAgentDockerfile(`FROM ${runtime}\nRUN npm i`), {
baseImage: runtime,
});
});

test("accepts a final stage descended from an approved named stage", () => {
assert.deepEqual(
validateAgentDockerfile(`
FROM node:22 AS build
RUN echo compiling
FROM ${runtime} AS platform
COPY --from=build /tmp/result /tmp/result
FROM platform AS final
RUN echo ready
`),
{ baseImage: runtime },
);
});

test("rejects an arbitrary or mutable final runtime", () => {
assert.throws(
() => validateAgentDockerfile("FROM ubuntu:24.04"),
/final Dockerfile stage must inherit/,
);
assert.throws(
() =>
validateAgentDockerfile(
"FROM registry.opencomputer.dev/serverless-agent:latest",
),
/final Dockerfile stage must inherit/,
);
});

test("rejects build arguments in FROM and custom frontends", () => {
assert.throws(
() => validateAgentDockerfile("ARG BASE\nFROM ${BASE}"),
/cannot use build arguments/,
);
assert.throws(
() => validateAgentDockerfile(`# syntax=example/custom:1\nFROM ${runtime}`),
/custom Dockerfile frontend/,
);
});

test("packages a deterministic narrow environment context", async () => {
const root = await mkdtemp(resolve(tmpdir(), "opencomputer-environment-"));
try {
await mkdir(resolve(root, "sandbox"));
await writeFile(resolve(root, "Dockerfile"), `FROM ${runtime}\n`);
await writeFile(resolve(root, "sandbox", "requirements.txt"), "ruff==1.0\n");
await writeFile(resolve(root, "ignored.txt"), "not in context\n");
const first = await buildAgentEnvironment(root);
const second = await buildAgentEnvironment(root);
assert.ok(first);
assert.equal(first.digest, second?.digest);
assert.equal(first.baseImage, runtime);
const body = JSON.parse(first.body.toString("utf8")) as {
files: Array<{ path: string }>;
};
assert.deepEqual(
body.files.map((file) => file.path),
["Dockerfile", "sandbox/requirements.txt"],
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("rejects symlinks and secret-looking context files", async () => {
const root = await mkdtemp(resolve(tmpdir(), "opencomputer-environment-"));
try {
await mkdir(resolve(root, "sandbox"));
await writeFile(resolve(root, "Dockerfile"), `FROM ${runtime}\n`);
await writeFile(resolve(root, "outside"), "secret");
await symlink(resolve(root, "outside"), resolve(root, "sandbox", "link"));
await assert.rejects(buildAgentEnvironment(root), /cannot include symlink/);
await rm(resolve(root, "sandbox", "link"));
await writeFile(resolve(root, "sandbox", ".env.local"), "TOKEN=nope\n");
await assert.rejects(buildAgentEnvironment(root), /cannot include sandbox\/\.env\.local/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
162 changes: 162 additions & 0 deletions cli/src/environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { createHash } from "node:crypto";
import { lstat, readFile, readdir } from "node:fs/promises";
import { relative, resolve } from "node:path";

const APPROVED_RUNTIME =
/^registry\.opencomputer\.dev\/serverless-agent:(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const MAX_CONTEXT_BYTES = 20 * 1024 * 1024;

export interface AgentEnvironmentSource {
body: Buffer;
digest: string;
size: number;
contentType: "application/vnd.opencomputer.agent-environment+json";
baseImage: string;
architecture: "linux/arm64";
}

interface DockerfileStage {
approvedBase?: string;
}

function dockerfileLines(source: string): string[] {
const lines: string[] = [];
let current = "";
for (const physical of source.replaceAll("\r\n", "\n").split("\n")) {
const continued = /\\\s*$/.test(physical);
current += `${current ? " " : ""}${physical.replace(/\\\s*$/, "").trim()}`;
if (!continued) {
if (current) lines.push(current);
current = "";
}
}
if (current) lines.push(current);
return lines;
}

export function validateAgentDockerfile(source: string): { baseImage: string } {
const stages = new Map<string, DockerfileStage>();
let finalStage: DockerfileStage | undefined;
for (const line of dockerfileLines(source)) {
if (/^#\s*syntax\s*=/i.test(line)) {
throw new Error("Agent Dockerfiles cannot select a custom Dockerfile frontend");
}
if (line.startsWith("#")) continue;
const [instruction = "", ...rest] = line.split(/\s+/);
if (instruction.toUpperCase() !== "FROM") continue;
const tokens = rest.filter((token) => !token.startsWith("--platform="));
const image = tokens[0];
if (!image) throw new Error("Every FROM instruction requires an image");
if (image.includes("$") || image.includes("${")) {
throw new Error("Agent Dockerfile FROM images cannot use build arguments");
}
const inherited = stages.get(image.toLowerCase());
const match = image.match(APPROVED_RUNTIME);
finalStage = {
approvedBase: match ? image : inherited?.approvedBase,
};
if (tokens[1]?.toUpperCase() === "AS") {
const alias = tokens[2];
if (!alias || !/^[a-zA-Z0-9_.-]+$/.test(alias)) {
throw new Error("Dockerfile stage aliases must be static names");
}
stages.set(alias.toLowerCase(), finalStage);
}
}
if (!finalStage) throw new Error("Agent Dockerfile must contain a FROM instruction");
if (!finalStage.approvedBase) {
throw new Error(
"The final Dockerfile stage must inherit from registry.opencomputer.dev/serverless-agent:<version>",
);
}
return { baseImage: finalStage.approvedBase };
}

async function collectSandboxFiles(
agentRoot: string,
directory: string,
): Promise<Array<{ path: string; content: string }>> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
const files: Array<{ path: string; content: string }> = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const path = resolve(directory, entry.name);
const normalized = relative(agentRoot, path).split("\\").join("/");
if (
entry.name === ".git" ||
entry.name === ".opencomputer" ||
entry.name === "node_modules" ||
entry.name === ".env" ||
entry.name.startsWith(".env.")
) {
throw new Error(`Agent environment context cannot include ${normalized}`);
}
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) {
throw new Error(`Agent environment context cannot include symlink ${normalized}`);
}
if (metadata.isDirectory()) {
files.push(...(await collectSandboxFiles(agentRoot, path)));
continue;
}
if (!metadata.isFile()) {
throw new Error(`Agent environment context only supports regular files: ${normalized}`);
}
if (metadata.size > MAX_FILE_BYTES) {
throw new Error(`Agent environment file exceeds 5 MiB: ${normalized}`);
}
files.push({ path: normalized, content: (await readFile(path)).toString("base64") });
}
return files;
}

export async function buildAgentEnvironment(
agentRoot: string,
): Promise<AgentEnvironmentSource | undefined> {
const dockerfilePath = resolve(agentRoot, "Dockerfile");
let dockerfile: Buffer;
try {
const metadata = await lstat(dockerfilePath);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error("Agent Dockerfile must be a regular file");
}
dockerfile = await readFile(dockerfilePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
const { baseImage } = validateAgentDockerfile(dockerfile.toString("utf8"));
const files = [
{ path: "Dockerfile", content: dockerfile.toString("base64") },
...(await collectSandboxFiles(agentRoot, resolve(agentRoot, "sandbox"))),
];
const decodedSize = files.reduce(
(total, file) => total + Buffer.byteLength(file.content, "base64"),
0,
);
if (decodedSize > MAX_CONTEXT_BYTES) {
throw new Error("Agent environment context exceeds 20 MiB");
}
const body = Buffer.from(
JSON.stringify({
version: 1,
architecture: "linux/arm64",
baseImage,
files,
}),
);
return {
body,
digest: createHash("sha256").update(body).digest("hex"),
size: body.byteLength,
contentType: "application/vnd.opencomputer.agent-environment+json",
baseImage,
architecture: "linux/arm64",
};
}
7 changes: 7 additions & 0 deletions cli/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { basename, dirname, relative, resolve } from "node:path";
import { Cron } from "croner";
import { build as bundle } from "esbuild";
import ts from "typescript";
import {
buildAgentEnvironment,
type AgentEnvironmentSource,
} from "./environment.js";

export interface AgentManifest {
schema: 1;
Expand All @@ -28,6 +32,7 @@ export interface BuiltAgentArtifact {
body: Buffer;
digest: string;
elapsedMs: number;
environment?: AgentEnvironmentSource;
}

export interface HttpConnectionManifest {
Expand Down Expand Up @@ -2063,6 +2068,7 @@ export async function buildAgentArtifact(
) as { connections?: string[]; httpConnections?: HttpConnectionManifest[] };
const connections = [...new Set(reactive.connections ?? [])].sort();
const httpConnections = reactive.httpConnections ?? [];
const environment = await buildAgentEnvironment(root);
const body = Buffer.from(
JSON.stringify({
version: 1,
Expand All @@ -2079,5 +2085,6 @@ export async function buildAgentArtifact(
body,
digest: createHash("sha256").update(body).digest("hex"),
elapsedMs: Math.round(performance.now() - startedAt),
...(environment ? { environment } : {}),
};
}
Loading
Loading