Skip to content
Open
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
28 changes: 26 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,35 @@ gawk, real curl/sqlite/duckdb/vim, …) compiled to `wasm32-wasip1` against a
custom host-syscall imports. Treat that target as **native POSIX**;
`wasm32-wasip1` is an implementation detail, not a feature ceiling.

### `wasm32-wasip1` capability rule

- **Never use `target_os = "wasi"` as evidence that a POSIX capability is
absent.** In agentOS it does not imply missing symlinks, executable modes,
TCP/UDP/Unix sockets, processes, signals, polling, or other Linux behavior.
- When an upstream Rust crate hides a supported API behind `cfg(unix)`, expose
the same behavior through the corresponding `std::os::wasi` or libc API. A
crate or Codex patch may adapt target-specific API names and cfg routing; it
must not replace working behavior with `Unsupported`, a no-op, a dummy
capability report, or a Windows fallback.
- Fix a genuinely missing POSIX operation in this order: Rust std, agentOS
libc/sysroot, then the host import and kernel implementation. Do not make an
application compile by disabling the operation one layer above the gap.
- A WASI stub is allowed only for a genuinely host-native architectural
boundary, not for a missing syscall. Its source must state which trusted
agentOS component owns the behavior and why the guest implementation must not
run. For example, guest HTTP uses the sidecar-brokered HTTP transport because
the sidecar owns network policy; that does not mean guest TCP sockets are
unsupported.
- Before accepting a new WASI patch or stub, audit every error path and cfg for
capability loss. Compilation alone is not validation: add or run a guest
behavior test for each POSIX surface the patch exposes.

- **We do not depend on stock WASI / wasi-libc.** The sysroot is ours. A missing
libc/POSIX API (`getrlimit`/`RLIMIT_NOFILE`, `getgroups`, spawn, fd dup, …) is
never a blocker — implement it (real, or a sane stub) in the patched
never a blocker — implement the real operation in the patched
std/libc/host-import layer. "WASI doesn't have X" is not a reason to stop; X is
ours to add.
ours to add. Do not substitute a success-returning stub, hard-coded value, or
target-level omission for a syscall agentOS can implement.
- **Fix portability one layer down, in the sysroot** — a new std/libc patch or a
new host import — not with `cfg(target_*)` branches or shims in the tool's own
source. A WASM-specific branch in application code usually means the fix
Expand Down
31 changes: 30 additions & 1 deletion crates/execution/assets/runners/wasm-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8647,7 +8647,26 @@ const hostProcessImport = {
}
},
pty_open(retMasterFdPtr, retSlaveFdPtr) {
return WASI_ERRNO_FAULT;
let masterFd = null;
let slaveFd = null;
try {
if (!hasRunnerOpenFdCapacity(2)) return WASI_ERRNO_MFILE;
const result = callSyncRpc('process.pty_open');
masterFd = registerKernelDelegateFd(result?.masterFd);
slaveFd = registerKernelDelegateFd(result?.slaveFd);
if (masterFd == null || slaveFd == null) return WASI_ERRNO_FAULT;
if (writeGuestUint32(retMasterFdPtr, masterFd) !== WASI_ERRNO_SUCCESS
|| writeGuestUint32(retSlaveFdPtr, slaveFd) !== WASI_ERRNO_SUCCESS) {
wasiImport.fd_close(masterFd);
wasiImport.fd_close(slaveFd);
return WASI_ERRNO_FAULT;
}
return WASI_ERRNO_SUCCESS;
} catch (error) {
if (masterFd != null) wasiImport.fd_close(masterFd);
if (slaveFd != null) wasiImport.fd_close(slaveFd);
return mapHostProcessError(error);
}
},
proc_sigaction(signal, action, maskLo, maskHi, flags) {
if (permissionTier !== 'full') {
Expand Down Expand Up @@ -12372,6 +12391,16 @@ const hostTtyImport = {
view.setUint16(rowsPtr >>> 0, size.rows & 0xffff, true);
return 0;
},
// Resize any kernel PTY descriptor and let the kernel deliver SIGWINCH to
// its foreground process group.
set_size(fd, cols, rows) {
try {
callSyncRpc('process.pty_resize', [fd >>> 0, cols >>> 0, rows >>> 0]);
return 0;
} catch (error) {
return mapHostProcessError(error);
}
},
// Toggle terminal raw mode on the guest's PTY. crossterm/pty_probe/vim call this
// instead of tcsetattr; route it to the kernel so the guest gets raw keystrokes.
set_raw_mode(enabled) {
Expand Down
2 changes: 2 additions & 0 deletions crates/execution/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3475,6 +3475,8 @@ if (typeof globalThis !== "undefined") {{
case "process.waitpid_transition":
case "process.itimer_real":
case "process.fd_pipe":
case "process.pty_open":
case "process.pty_resize":
case "process.fd_open":
case "process.path_open_at":
case "process.path_mkdir_at":
Expand Down
27 changes: 27 additions & 0 deletions crates/native-sidecar/src/execution/javascript/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const ALLOWED_WASM_PROCESS_SYNC_RPCS: &[&str] = &[
"process.waitpid_transition",
"process.itimer_real",
"process.fd_pipe",
"process.pty_open",
"process.pty_resize",
"process.fd_open",
"process.path_open_at",
"process.path_mkdir_at",
Expand Down Expand Up @@ -968,6 +970,31 @@ where
.open_pipe(EXECUTION_DRIVER_NAME, process.kernel_pid)
.map(|(read_fd, write_fd)| json!({ "readFd": read_fd, "writeFd": write_fd }))
.map_err(kernel_error),
"process.pty_open" => kernel
.open_pty(EXECUTION_DRIVER_NAME, process.kernel_pid)
.map(|(master_fd, slave_fd, path)| {
json!({ "masterFd": master_fd, "slaveFd": slave_fd, "path": path })
})
.map_err(kernel_error),
"process.pty_resize" => {
let fd = javascript_sync_rpc_arg_u32(&request.args, 0, "PTY fd")?;
let cols = u16::try_from(javascript_sync_rpc_arg_u32(
&request.args,
1,
"PTY columns",
)?)
.map_err(|_| SidecarError::InvalidState(String::from("EINVAL: PTY columns exceed u16")))?;
let rows = u16::try_from(javascript_sync_rpc_arg_u32(
&request.args,
2,
"PTY rows",
)?)
.map_err(|_| SidecarError::InvalidState(String::from("EINVAL: PTY rows exceed u16")))?;
kernel
.pty_resize(EXECUTION_DRIVER_NAME, process.kernel_pid, fd, cols, rows)
.map(|_| Value::Null)
.map_err(kernel_error)
}
"process.fd_open" => {
let path = javascript_sync_rpc_arg_str(&request.args, 0, "fd_open path")?;
let flags = javascript_sync_rpc_arg_u32(&request.args, 1, "fd_open flags")?;
Expand Down
66 changes: 63 additions & 3 deletions examples/codex/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ Write commit messages in the imperative mood and keep the subject under 50 chara
async function withMcp() {
// Pre-install the MCP server so `npx` is silent — first-run install output
// would otherwise corrupt the MCP stdio handshake ("Connection closed").
await agent.process.exec("npm install -g @modelcontextprotocol/server-filesystem");
await agent.process.exec(
"npm install -g @modelcontextprotocol/server-filesystem",
);

const config = `[mcp_servers.filesystem]
command = "npx"
Expand All @@ -79,6 +81,62 @@ http_headers = { Authorization = "Bearer my-token" }
}
// docs:end mcp

// docs:start agent-plugins
// ── Agent Plugins ─────────────────────────────────────────────────
//
// Install a portable Agent Plugin into Codex's cache before opening the
// session. Other compatible clients use their own installation locations.
async function withAgentPlugin() {
const pluginRoot =
"/home/agentos/.codex/plugins/cache/local/release-workflow/1.0.0";
await agent.filesystem.mkdir(`${pluginRoot}/.codex-plugin`);
await agent.filesystem.mkdir(`${pluginRoot}/skills/release-notes`);

await agent.filesystem.writeFile(
`${pluginRoot}/.codex-plugin/plugin.json`,
JSON.stringify({
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
name: "release-workflow",
version: "1.0.0",
description: "Project release workflow",
}),
);
await agent.filesystem.writeFile(
`${pluginRoot}/skills/release-notes/SKILL.md`,
`---
name: release-notes
description: Write release notes for this project.
---

Summarize user-visible changes under Added, Changed, and Fixed.
`,
);

await agent.filesystem.writeFile(
"/home/agentos/.codex/config.toml",
`[features]
plugins = true

[plugins."release-workflow@local"]
enabled = true
`,
);

await agent.sessions.open({
agent: "codex",
env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY! },
});
await agent.sessions.prompt({
content: [
{
type: "text",
text: "Use $release-workflow:release-notes for the current changes.",
},
],
});
}
// docs:end agent-plugins

// ── Skills + MCP together ─────────────────────────────────────────
async function withSkillAndMcp() {
const skill = `---
Expand All @@ -97,7 +155,9 @@ Write commit messages in the imperative mood and keep the subject under 50 chara

// Pre-install the MCP server so `npx` is silent — first-run install output
// would otherwise corrupt the MCP stdio handshake ("Connection closed").
await agent.process.exec("npm install -g @modelcontextprotocol/server-filesystem");
await agent.process.exec(
"npm install -g @modelcontextprotocol/server-filesystem",
);

const config = `[mcp_servers.filesystem]
command = "npx"
Expand All @@ -122,4 +182,4 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/agentos"]
console.log(result.message?.content ?? []);
}

export { quickStart, withSkill, withMcp, withSkillAndMcp };
export { quickStart, withAgentPlugin, withMcp, withSkill, withSkillAndMcp };
66 changes: 64 additions & 2 deletions packages/core/tests/codex-fullturn.nightly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
} from "./helpers/openai-responses-mock.js";
import { REGISTRY_SOFTWARE } from "./helpers/registry-commands.js";

const codexConfig = `[features]
const codexConfig = (
openAiBaseUrl: string,
) => `openai_base_url = "${openAiBaseUrl}"

[features]
# Shell snapshots spawn a pre-turn shell subprocess. The real turn coverage
# below does not need that optional context, and disabling it keeps the WASI VM
# focused on the codex-core model/tool path under test.
Expand All @@ -34,6 +38,7 @@ async function runSessionTurn(
fixtures: ResponsesFixture[],
start: Record<string, unknown>,
stdinTail = "",
prepare?: (vm: AgentOs, openAiBaseUrl: string) => Promise<void>,
) {
const mock = await startResponsesMock(fixtures);
const vm = await AgentOs.create({
Expand All @@ -53,8 +58,9 @@ async function runSessionTurn(
await vm.execArgv("mkdir", ["-p", "/home/agentos/.codex"]);
await vm.writeFile(
"/home/agentos/.codex/config.toml",
new TextEncoder().encode(codexConfig),
new TextEncoder().encode(codexConfig(`${mock.url}/v1`)),
);
await prepare?.(vm, `${mock.url}/v1`);
const r = await vm.execArgv("codex-exec", ["--session-turn"], {
timeout: 45000,
stdin,
Expand Down Expand Up @@ -95,6 +101,62 @@ const finalText = (text: string): ResponsesFixture => ({
describe.skipIf(!hasCodexExecArtifact)(
"codex full turn (real codex agent in the VM, mock OpenAI Responses)",
() => {
test("loads a portable Agent Plugin skill into the model context", async () => {
const pluginRoot =
"/home/agentos/.codex/plugins/cache/local/release-workflow/1.0.0";
const { stdout, stderr, exitCode, requests } = await runSessionTurn(
[finalText("used the release notes skill")],
{ prompt: "Use $release-workflow:release-notes for these changes." },
"",
async (vm, openAiBaseUrl) => {
await vm.execArgv("mkdir", ["-p", `${pluginRoot}/.codex-plugin`]);
await vm.execArgv("mkdir", [
"-p",
`${pluginRoot}/skills/release-notes`,
]);
await vm.writeFile(
`${pluginRoot}/.codex-plugin/plugin.json`,
new TextEncoder().encode(
JSON.stringify({
$schema:
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
name: "release-workflow",
version: "1.0.0",
description: "Project release workflow",
}),
),
);
await vm.writeFile(
`${pluginRoot}/skills/release-notes/SKILL.md`,
new TextEncoder().encode(`---
name: release-notes
description: Write release notes for this project.
---

Summarize user-visible changes under Added, Changed, and Fixed.
`),
);
await vm.writeFile(
"/home/agentos/.codex/config.toml",
new TextEncoder().encode(`${codexConfig(openAiBaseUrl)}
plugins = true

[plugins."release-workflow@local"]
enabled = true
`),
);
},
);
expect(
requests.length,
`codex-exec did not call mock Responses; exitCode=${exitCode}; stderr=${stderr}; stdout=${stdout}`,
).toBeGreaterThan(0);
const requestBody = JSON.stringify(requests[0]);
expect(requestBody).toContain("release-workflow:release-notes");
expect(requestBody).toContain("Write release notes for this project.");
expect(stdout).toContain('"type":"done"');
}, 70000);

test("codex-exec --session-turn completes a model turn end-to-end", async () => {
const { stdout, stderr, exitCode, requests } = await runSessionTurn(
[finalText("hello from codex")],
Expand Down
2 changes: 1 addition & 1 deletion toolchain/codex-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
rivet-dev/codex@8f4655a9dbfc7db91768c78ccaea7357ba47127e
rivet-dev/codex@aed385c00e5156596d57edb8e4a5f02ec6b8172f
Loading
Loading