Skip to content

Commit 2ba8d8e

Browse files
NathanFlurryclaude
andcommitted
feat(python): Pyodide python/python3 CLI + whole-VM-root filesystem + VFS-persistent pip
- rpc-client: route python/python3 through the direct command path (like node); report the python driver in processTree() - /bin/python + /bin/python3 stubs so the guest shell resolves python on PATH (sh -c "python ...", pipelines) - e2e: tests/python-cli.test.ts (-c, vm.exec, script anywhere on the VM fs, -m, python3, stdin, cross-runtime write->host read, guest shell + pipelines) - docs: Python Runtime page (why Pyodide vs native wasip1, virtual-OS integration with whole VM filesystem, packages/native modules, persistent pip, sharp edges) - pin @secure-exec/* to published preview 0.0.0-python-cli.48bf1fc (rebased onto current secure-exec main; crates build from clone at 48bf1fc via prepare-build) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cd4c53a commit 2ba8d8e

9 files changed

Lines changed: 302 additions & 66 deletions

File tree

‎packages/core/src/agent-os.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,16 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
11661166
"/etc/agentos",
11671167
] as const;
11681168

1169-
const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"] as const;
1169+
// Runtime commands that get a `/bin/<cmd>` stub at bootstrap so the guest shell
1170+
// resolves them on PATH (e.g. `sh -c "python ..."`, pipelines). The sidecar
1171+
// intercepts these by name and routes them to the embedded V8 / Pyodide runtime.
1172+
const RUNTIME_BOOTSTRAP_COMMANDS = [
1173+
"node",
1174+
"npm",
1175+
"npx",
1176+
"python",
1177+
"python3",
1178+
] as const;
11701179
const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
11711180
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
11721181
const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
@@ -2800,7 +2809,7 @@ export class AgentOs {
28002809
options?.rootFilesystem,
28012810
[
28022811
...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
2803-
...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
2812+
...RUNTIME_BOOTSTRAP_COMMANDS,
28042813
...toolBootstrapCommands,
28052814
],
28062815
);

‎packages/core/src/runtime-compat.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ const S_IFDIR = 0o040000;
3535
const S_IFLNK = 0o120000;
3636
const MAX_SYMLINK_DEPTH = 40;
3737
const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
38-
const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"] as const;
38+
const NODE_RUNTIME_BOOTSTRAP_COMMANDS = [
39+
"node",
40+
"npm",
41+
"npx",
42+
"python",
43+
"python3",
44+
] as const;
3945
const KERNEL_POSIX_BOOTSTRAP_DIRS = [
4046
"/dev",
4147
"/proc",

‎packages/core/src/sidecar/rpc-client.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,12 @@ function canUseDirectExec(
191191
driver: string | undefined,
192192
commandName: string | undefined,
193193
): boolean {
194-
return driver === "wasmvm" || (driver === "node" && commandName === "node");
194+
return (
195+
driver === "wasmvm" ||
196+
(driver === "node" && commandName === "node") ||
197+
(driver === "python" &&
198+
(commandName === "python" || commandName === "python3"))
199+
);
195200
}
196201

197202
function shellSingleQuote(value: string): string {
@@ -741,7 +746,12 @@ export class NativeSidecarKernelProxy {
741746
processId,
742747
command: spawnCommand,
743748
args: spawnArgs,
744-
driver: spawnCommand === "node" ? "node" : "wasmvm",
749+
driver:
750+
spawnCommand === "node"
751+
? "node"
752+
: spawnCommand === "python" || spawnCommand === "python3"
753+
? "python"
754+
: "wasmvm",
745755
cwd: options?.cwd ?? this.cwd,
746756
env: {
747757
...(options?.env ?? {}),
@@ -2342,6 +2352,10 @@ function buildCommandMap(
23422352
["node", "node"],
23432353
["npm", "node"],
23442354
["npx", "node"],
2355+
// `python` / `python3` are served by the embedded Pyodide runtime,
2356+
// mirroring how `node` is served by the embedded V8 runtime.
2357+
["python", "python"],
2358+
["python3", "python"],
23452359
]);
23462360
for (const name of commandGuestPaths.keys()) {
23472361
commands.set(name, "wasmvm");

‎packages/core/tests/agentos-base-filesystem.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ describe("AgentOs base filesystem", () => {
144144
expect(await vm.exists("/boot")).toBe(true);
145145
expect(await vm.exists("/usr/bin/env")).toBe(true);
146146
expect(await vm.exists("/bin/node")).toBe(true);
147-
expect(await vm.exists("/bin/python")).toBe(false);
147+
expect(await vm.exists("/bin/python")).toBe(true);
148+
expect(await vm.exists("/bin/python3")).toBe(true);
148149
await expect(vm.writeFile("/tmp/blocked.txt", "blocked")).rejects.toThrow(
149150
"EROFS",
150151
);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
2+
import { AgentOs } from "../src/index.js";
3+
import { hasRegistryCommands, REGISTRY_SOFTWARE } from "./helpers/registry-commands.js";
4+
5+
// End-to-end coverage for the Pyodide-powered `python` / `python3` CLI exposed
6+
// by secure-exec, driven through the public AgentOs API. `python` resolves as a
7+
// runtime command (like `node`): `vm.exec()` / `vm.execArgv()` / `spawn()` route
8+
// it directly to the embedded Pyodide runtime, and a `/bin/python` stub also
9+
// makes it resolvable on the guest shell `PATH`. The Python runtime bridges the
10+
// whole VM filesystem into Pyodide, so scripts and file I/O work anywhere on the
11+
// VM (these tests use `/tmp`).
12+
describe("python CLI (Pyodide runtime)", () => {
13+
let vm: AgentOs;
14+
15+
beforeAll(async () => {
16+
vm = await AgentOs.create();
17+
}, 120_000);
18+
19+
afterAll(async () => {
20+
await vm?.dispose();
21+
});
22+
23+
test(
24+
"python -c runs inline code",
25+
async () => {
26+
const result = await vm.execArgv("python", ["-c", "print(1 + 1)"]);
27+
expect(result.exitCode, result.stderr).toBe(0);
28+
expect(result.stdout.trim()).toBe("2");
29+
},
30+
120_000,
31+
);
32+
33+
test(
34+
"python via vm.exec (agent command path) runs inline code",
35+
async () => {
36+
const result = await vm.exec('python -c "print(2 + 3)"');
37+
expect(result.exitCode, result.stderr).toBe(0);
38+
expect(result.stdout.trim()).toBe("5");
39+
},
40+
120_000,
41+
);
42+
43+
test(
44+
"python runs a script file anywhere on the VM filesystem with sys.argv",
45+
async () => {
46+
// /tmp is bridged into Python via the whole-root mount (not just /workspace).
47+
await vm.writeFile(
48+
"/tmp/argv.py",
49+
"import sys\nprint(','.join(sys.argv))\n",
50+
);
51+
const result = await vm.execArgv("python", [
52+
"/tmp/argv.py",
53+
"alpha",
54+
"beta",
55+
]);
56+
expect(result.exitCode, result.stderr).toBe(0);
57+
expect(result.stdout.trim()).toBe("/tmp/argv.py,alpha,beta");
58+
},
59+
120_000,
60+
);
61+
62+
test(
63+
"python writes to the VM filesystem, visible to the host (cross-runtime)",
64+
async () => {
65+
const write = await vm.execArgv("python", [
66+
"-c",
67+
"open('/tmp/from-python.txt','w').write('written-by-python')",
68+
]);
69+
expect(write.exitCode, write.stderr).toBe(0);
70+
// The write landed in the kernel VFS, so the host sees it too.
71+
const contents = await vm.readFile("/tmp/from-python.txt");
72+
expect(Buffer.from(contents).toString("utf8")).toBe("written-by-python");
73+
},
74+
120_000,
75+
);
76+
77+
test(
78+
"python -m runs a module",
79+
async () => {
80+
const result = await vm.execArgv("python", ["-m", "this"]);
81+
expect(result.exitCode, result.stderr).toBe(0);
82+
expect(result.stdout).toContain("Beautiful is better than ugly");
83+
},
84+
120_000,
85+
);
86+
87+
test(
88+
"python3 alias runs inline code",
89+
async () => {
90+
const result = await vm.execArgv("python3", ["-c", "print(6 * 7)"]);
91+
expect(result.exitCode, result.stderr).toBe(0);
92+
expect(result.stdout.trim()).toBe("42");
93+
},
94+
120_000,
95+
);
96+
97+
test(
98+
"python - reads the program from stdin",
99+
async () => {
100+
const chunks: string[] = [];
101+
const { pid } = vm.spawn("python", ["-"], {
102+
onStdout: (data) => chunks.push(Buffer.from(data).toString("utf8")),
103+
});
104+
vm.writeProcessStdin(pid, "print('from stdin program')\n");
105+
vm.closeProcessStdin(pid);
106+
const exitCode = await vm.waitProcess(pid);
107+
// Native-sidecar process_output can lag the exit notification by a turn.
108+
await new Promise((resolve) => setTimeout(resolve, 0));
109+
expect(exitCode).toBe(0);
110+
expect(chunks.join("")).toContain("from stdin program");
111+
},
112+
120_000,
113+
);
114+
115+
// The guest-shell path needs the WASM `sh` from the registry. `python` resolves
116+
// on the shell PATH via its `/bin/python` stub, so `sh -c`/pipelines work.
117+
test.runIf(hasRegistryCommands)(
118+
"python runs through the guest shell and pipelines",
119+
async () => {
120+
const shellVm = await AgentOs.create({ software: REGISTRY_SOFTWARE });
121+
try {
122+
const direct = await shellVm.exec('sh -c "python -c \'print(2 + 3)\'"');
123+
expect(direct.exitCode, direct.stderr).toBe(0);
124+
expect(direct.stdout.trim()).toBe("5");
125+
126+
const piped = await shellVm.exec('echo "print(6 * 7)" | python -');
127+
expect(piped.exitCode, piped.stderr).toBe(0);
128+
expect(piped.stdout.trim()).toBe("42");
129+
} finally {
130+
await shellVm.dispose();
131+
}
132+
},
133+
120_000,
134+
);
135+
});

0 commit comments

Comments
 (0)