Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/core/src/agent-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@ export class AgentOs {
await kernel.mount(
createWasmVmRuntime(
processed.commandDirs.length > 0
? { commandDirs: processed.commandDirs }
? {
commandDirs: processed.commandDirs,
permissions: processed.commandPermissions,
}
: undefined,
),
);
Expand Down
76 changes: 75 additions & 1 deletion packages/core/src/packages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync, realpathSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import type { PermissionTier } from "@rivet-dev/agent-os-posix";

/**
* Resolve a package directory by walking up the directory tree.
Expand Down Expand Up @@ -99,6 +100,7 @@ export interface WasmCommandSoftwareDescriptor extends SoftwareDescriptor {
full?: string[];
readWrite?: string[];
readOnly?: string[] | "*";
isolated?: string[];
};
}

Expand Down Expand Up @@ -228,6 +230,8 @@ export function defineSoftware<T extends AnySoftwareDescriptor>(desc: T): T {
export interface ProcessedSoftware {
/** WASM command directories to pass to the WasmVM driver. */
commandDirs: string[];
/** Per-command permission tiers propagated into the WasmVM runtime. */
commandPermissions: Record<string, PermissionTier>;
/** Host-to-VM path mappings for ModuleAccessFileSystem. */
softwareRoots: SoftwareRoot[];
/** Agent configs registered by agent software. */
Expand All @@ -239,6 +243,73 @@ function isTypedDescriptor(desc: AnySoftwareDescriptor): desc is AgentSoftwareDe
return "type" in desc && typeof (desc as SoftwareDescriptor).type === "string";
}

const VALID_PERMISSION_TIERS = new Set<PermissionTier>([
"full",
"read-write",
"read-only",
"isolated",
]);

function isPermissionTier(value: unknown): value is PermissionTier {
return typeof value === "string" && VALID_PERMISSION_TIERS.has(value as PermissionTier);
}

function registerPermission(
commandPermissions: Record<string, PermissionTier>,
commandName: string,
tier: PermissionTier,
): void {
if (commandName in commandPermissions) return;
commandPermissions[commandName] = tier;
}

function collectRegistryPackagePermissions(
commandPermissions: Record<string, PermissionTier>,
pkg: WasmCommandDirDescriptor,
): void {
const rawCommands = (pkg as { commands?: unknown }).commands;
if (!Array.isArray(rawCommands)) return;

for (const rawCommand of rawCommands) {
if (
typeof rawCommand !== "object" ||
rawCommand === null ||
!Object.hasOwn(rawCommand, "name") ||
!Object.hasOwn(rawCommand, "permissionTier")
) {
continue;
}

const name = (rawCommand as { name: unknown }).name;
const permissionTier = (rawCommand as { permissionTier: unknown }).permissionTier;
if (typeof name !== "string" || !isPermissionTier(permissionTier)) continue;
registerPermission(commandPermissions, name, permissionTier);
}
}

function collectTypedDescriptorPermissions(
commandPermissions: Record<string, PermissionTier>,
pkg: WasmCommandSoftwareDescriptor,
): void {
const permissions = pkg.permissions;
if (!permissions) return;

for (const commandName of permissions.full ?? []) {
registerPermission(commandPermissions, commandName, "full");
}
for (const commandName of permissions.readWrite ?? []) {
registerPermission(commandPermissions, commandName, "read-write");
}
if (Array.isArray(permissions.readOnly)) {
for (const commandName of permissions.readOnly) {
registerPermission(commandPermissions, commandName, "read-only");
}
}
for (const commandName of permissions.isolated ?? []) {
registerPermission(commandPermissions, commandName, "isolated");
}
}

/**
* Process an array of software descriptors at boot time.
* Collects WASM command dirs, module access roots, and agent configurations.
Expand All @@ -251,6 +322,7 @@ export function processSoftware(
software: SoftwareInput[],
): ProcessedSoftware {
const commandDirs: string[] = [];
const commandPermissions: Record<string, PermissionTier> = {};
const softwareRoots: SoftwareRoot[] = [];
const agentConfigs = new Map<string, AgentConfig>();

Expand All @@ -261,12 +333,14 @@ export function processSoftware(
if (!isTypedDescriptor(pkg)) {
// Duck-typed: any object with commandDir is a WASM command source.
commandDirs.push(pkg.commandDir);
collectRegistryPackagePermissions(commandPermissions, pkg);
continue;
}

switch (pkg.type) {
case "wasm-commands": {
commandDirs.push(pkg.commandDir);
collectTypedDescriptorPermissions(commandPermissions, pkg);
break;
}

Expand Down Expand Up @@ -313,5 +387,5 @@ export function processSoftware(
}
}

return { commandDirs, softwareRoots, agentConfigs };
return { commandDirs, commandPermissions, softwareRoots, agentConfigs };
}
145 changes: 145 additions & 0 deletions packages/core/tests/duckdb-package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { existsSync } from "node:fs";
import coreutils from "@rivet-dev/agent-os-coreutils";
import duckdb from "../../../registry/software/duckdb/dist/index.js";
import httpGet from "../../../registry/software/http-get/dist/index.js";
import { AgentOs } from "../src/index.js";

const hasDuckdbPackage = existsSync(`${duckdb.commandDir}/duckdb`);
const hasHttpGetPackage = existsSync(`${httpGet.commandDir}/http_get`);
const hasCoreutilsPackage = existsSync(`${coreutils.commandDir}/sh`);

function closeServer(server: Server) {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}

describe.skipIf(!hasDuckdbPackage || !hasHttpGetPackage || !hasCoreutilsPackage)(
"duckdb registry package",
() => {
let vm: AgentOs;

beforeEach(async () => {
vm = await AgentOs.create({ software: [coreutils, httpGet, duckdb] });
});

afterEach(async () => {
await vm.dispose();
});

test("runs file-backed DuckDB DML through the registry package path", async () => {
let result = await vm.exec(
`duckdb -csv /tmp/app.duckdb -c "CREATE TABLE items(id INTEGER, value INTEGER); INSERT INTO items VALUES (1, 10), (2, 20); UPDATE items SET value = value + 1 WHERE id = 2;"`,
);
expect(result.exitCode).toBe(0);

result = await vm.exec(
`duckdb -csv /tmp/app.duckdb -c "SELECT id, value FROM items ORDER BY id;"`,
);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("id,value\n1,10\n2,21");
expect(await vm.exists("/tmp/app.duckdb")).toBe(true);
});

test("fetches remote CSV data into the VFS and queries it from DuckDB", async () => {
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
if (req.url === "/remote.csv") {
res.writeHead(200, { "Content-Type": "text/csv" });
res.end("city,value\nsf,3\nla,5\n");
return;
}

res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
});

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("failed to bind test HTTP server");
}

let result = await vm.exec(
`http_get ${address.port} /remote.csv /tmp/remote.csv`,
);
expect(result.exitCode).toBe(0);

result = await vm.exec(
`duckdb -csv -c "SELECT SUM(value) AS total FROM read_csv_auto('/tmp/remote.csv');"`,
);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("total\n8");
} finally {
await closeServer(server);
}
});

test("keeps DuckDB itself file-scoped while the network helper handles remote fetches", async () => {
let requests = 0;
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
requests += 1;
if (req.url === "/remote.csv") {
res.writeHead(200, { "Content-Type": "text/csv" });
res.end("city,value\nsf,3\nla,5\n");
return;
}

res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
});

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("failed to bind test HTTP server");
}

const result = await vm.exec(
`duckdb -csv -c "SELECT SUM(value) AS total FROM read_csv_auto('http://127.0.0.1:${address.port}/remote.csv');"`,
);
expect(result.exitCode).not.toBe(0);
expect(requests).toBe(0);
} finally {
await closeServer(server);
}
});

test("propagates registry package command permission tiers into the runtime", async () => {
await vm.dispose();

const httpGetReadOnly = {
...httpGet,
commands: [{ name: "http_get", permissionTier: "read-only" as const }],
};
vm = await AgentOs.create({ software: [coreutils, httpGetReadOnly] });

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
});

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));

try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("failed to bind test HTTP server");
}

const result = await vm.exec(`http_get ${address.port} /blocked`);
expect(result.exitCode).not.toBe(0);
} finally {
await closeServer(server);
}
});
},
);
7 changes: 7 additions & 0 deletions packages/posix/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,13 @@ class WasmVmRuntimeDriver implements RuntimeDriver {
await kernel.vfs.mkdir(scopedProcPath(pid, msg.args.path as string));
break;
}
case 'vfsTruncate': {
await kernel.vfs.truncate(
scopedProcPath(pid, msg.args.path as string),
msg.args.length as number,
);
break;
}
case 'vfsUnlink': {
await kernel.vfs.removeFile(scopedProcPath(pid, msg.args.path as string));
break;
Expand Down
Loading
Loading