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
23 changes: 18 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,24 +148,37 @@ jobs:
set -u
FAILURES=""

# publisher: "npm" preserves the binary executable bit (pnpm publish
# normalizes file modes to 0644, stripping +x). Platform binary
# packages must use npm publish; workspace packages use pnpm publish so
# the `workspace:*` protocol is rewritten to concrete versions. This
# mirrors how rivet-dev/rivet ships rivet-engine via @rivetkit/engine-cli.
publish_dir() {
local dir="$1"
local publisher="${2:-pnpm}"
local name version
name=$(jq -r .name "$dir/package.json")
version=$(jq -r .version "$dir/package.json")
if npm view "${name}@${version}" version >/dev/null 2>&1; then
echo "⏭ ${name}@${version} already published, skipping."
return 0
fi
echo "Publishing ${name}@${version}..."
if ! (cd "$dir" && pnpm publish --access public --tag "${NPM_TAG}" --no-git-checks); then
FAILURES="${FAILURES} ${name}"
echo "Publishing ${name}@${version} via ${publisher}..."
if [ "$publisher" = "npm" ]; then
if ! (cd "$dir" && npm publish --access public --tag "${NPM_TAG}"); then
FAILURES="${FAILURES} ${name}"
fi
else
if ! (cd "$dir" && pnpm publish --access public --tag "${NPM_TAG}" --no-git-checks); then
FAILURES="${FAILURES} ${name}"
fi
fi
}

# 1. Platform binary packages (not pnpm workspace members).
# 1. Platform binary packages (not pnpm workspace members). Published
# with npm to keep the binary's executable bit (see publish_dir).
for p in ${PRESENT}; do
publish_dir "packages/sidecar-binary/npm/${p}"
publish_dir "packages/sidecar-binary/npm/${p}" npm
done

# 2. Workspace packages (skip root, skip registry/software/). This
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ Agent OS is a **fully virtualized operating system**. The kernel, written as a R
- **The default VM filesystem model should be Docker-like.** Layered overlay view with one writable upper layer on top of one or more immutable lower snapshot layers.
- **Everything runs inside the VM.** Agent processes, servers, network requests -- all spawned inside the Agent OS kernel, never on the host. This is a hard rule with no exceptions.

## Native Binary Distribution

- **Ship the `agent-os-sidecar` binary to npm the same way `rivet-dev/rivet` ships `rivet-engine`** via `@rivetkit/engine-cli` (reference: the rivet repo's `rivetkit-typescript/packages/engine-cli/index.js` resolver and `.github/workflows/publish.yaml`). The pattern: a meta resolver package (`@rivet-dev/agent-os-sidecar`) with platform packages (`@rivet-dev/agent-os-sidecar-<platform>`) declared as `optionalDependencies`, the binary bundled in each platform tarball, selected at install by npm `os`/`cpu`/`libc`.
- **Platform binary packages MUST be published with `npm publish`, never `pnpm publish`.** `pnpm publish` normalizes file modes to `0644` and strips the binary's executable bit; `npm publish` preserves `0755`. The release workflow publishes platform packages via npm and workspace packages via pnpm (for `workspace:*` rewriting). Because `npm publish` preserves `0755`, the resolver does NOT `chmod` at runtime, matching `@rivetkit/engine-cli`'s `getEnginePath()`.
- **Spawn the native binary the same way `rivet-dev/rivet` spawns `rivet-engine`: thread the resolved absolute path as a typed value down to `Command::new(path)`, not through a process-global env var.** Reference `rivetkit-core/src/engine_process.rs`, where `engineBinaryPath` flows TS (`getEnginePath()`) → typed serve config → `Command::new(binary_path)`. The agent-os client must mirror this: the npm-resolved `getSidecarPath()` value is passed through `AgentOsConfig` to `SidecarTransport::spawn(...)` and into `Command::new(...)`. The `AGENT_OS_SIDECAR_BIN` env var stays only as a debug/override fallback; callers must not rely on `std::env::set_var` to hand the path to the spawn.

## Secure-Exec Reference Implementation

The Rust sidecar kernel was migrated from a working JavaScript kernel (`@secure-exec/core` + `@secure-exec/nodejs` + `@secure-exec/v8`). The original source is at `/home/nathan/secure-exec-1/` (tagged `v0.2.1`), and recovered polyfill/bridge code lives at `~/.agents/recovery/secure-exec/`. **When something doesn't work in the Rust V8 isolate runtime, check how secure-exec handled it first** — the answer is almost always already there. Key reference files:
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

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

6 changes: 4 additions & 2 deletions crates/client/src/agent_os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,11 @@ impl AgentOs {
let sidecar = match &config.sidecar {
Some(crate::config::AgentOsSidecarConfig::Explicit { handle }) => handle.clone(),
Some(crate::config::AgentOsSidecarConfig::Shared { pool }) => {
AgentOs::get_shared_sidecar(pool.clone()).await?
AgentOs::get_shared_sidecar(pool.clone(), config.sidecar_binary_path.clone()).await?
}
None => {
AgentOs::get_shared_sidecar(None, config.sidecar_binary_path.clone()).await?
}
None => AgentOs::get_shared_sidecar(None).await?,
};
let (transport, connection_id, max_frame_bytes) = sidecar.ensure_connection().await?;

Expand Down
10 changes: 10 additions & 0 deletions crates/client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ pub struct AgentOsConfig {
pub permissions: Option<Permissions>,
/// Sidecar placement/config. Default: shared `default` pool.
pub sidecar: Option<AgentOsSidecarConfig>,
/// Absolute path to the `agent-os-sidecar` binary, resolved from the npm
/// package on the TypeScript side. Threaded to `SidecarTransport::spawn`
/// (mirroring rivetkit's `engine_binary_path`) instead of relying on the
/// `AGENT_OS_SIDECAR_BIN` env var. `None` falls back to env, then `PATH`.
pub sidecar_binary_path: Option<String>,
}

/// Builder for [`AgentOsConfig`].
Expand Down Expand Up @@ -108,6 +113,11 @@ impl AgentOsConfigBuilder {
self
}

pub fn sidecar_binary_path(mut self, path: impl Into<String>) -> Self {
self.config.sidecar_binary_path = Some(path.into());
self
}

pub fn build(self) -> AgentOsConfig {
self.config
}
Expand Down
12 changes: 10 additions & 2 deletions crates/client/src/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ pub struct AgentOsSidecar {
pub(crate) shared_pool: Option<String>,
pub(crate) state: AtomicU8,
pub(crate) active_vm_count: AtomicU32,
/// Absolute path to the `agent-os-sidecar` binary, threaded from
/// `AgentOsConfig` (resolved from the npm package on the TS side) and passed
/// to `SidecarTransport::spawn` instead of relying on a process-global env.
pub(crate) sidecar_binary_path: Option<String>,
/// The shared sidecar process + authenticated connection, established on the first VM `create`
/// against this sidecar and reused by every subsequent VM in the same (shared) sidecar.
pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
Expand All @@ -121,13 +125,15 @@ impl AgentOsSidecar {
sidecar_id: impl Into<String>,
placement: AgentOsSidecarPlacement,
shared_pool: Option<String>,
sidecar_binary_path: Option<String>,
) -> Self {
Self {
sidecar_id: sidecar_id.into(),
placement,
shared_pool,
state: AtomicU8::new(SidecarState::Ready.as_u8()),
active_vm_count: AtomicU32::new(0),
sidecar_binary_path,
connection: tokio::sync::Mutex::new(None),
}
}
Expand All @@ -149,7 +155,7 @@ impl AgentOsSidecar {
));
}

let transport = SidecarTransport::spawn().await?;
let transport = SidecarTransport::spawn(self.sidecar_binary_path.clone()).await?;
let authed = match transport
.request(
OwnershipScope::connection("client-hint"),
Expand Down Expand Up @@ -323,7 +329,7 @@ impl AgentOs {
let placement = AgentOsSidecarPlacement::Explicit {
sidecar_id: sidecar_id.clone(),
};
Ok(Arc::new(AgentOsSidecar::new(sidecar_id, placement, None)))
Ok(Arc::new(AgentOsSidecar::new(sidecar_id, placement, None, None)))
}

/// Get (or create) a pooled shared sidecar. Pool defaults to `"default"`. Uses the process-global
Expand All @@ -336,6 +342,7 @@ impl AgentOs {
/// atomically with `entry`/`insert` so two racing callers converge on a single live handle.
pub async fn get_shared_sidecar(
pool: Option<String>,
sidecar_binary_path: Option<String>,
) -> Result<Arc<AgentOsSidecar>, ClientError> {
let pool = pool.unwrap_or_else(|| "default".to_string());
let cache = shared_sidecars();
Expand All @@ -361,6 +368,7 @@ impl AgentOs {
pool: placement_pool,
},
Some(pool.clone()),
sidecar_binary_path,
));

// Insert atomically, replacing a stale (disposed) entry but yielding to a live one that a
Expand Down
10 changes: 8 additions & 2 deletions crates/client/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,14 @@ impl SidecarTransport {
///
/// Does NOT run the handshake; `AgentOs::create` drives Authenticate -> OpenSession -> CreateVm ->
/// ConfigureVm using [`request`](Self::request) once the transport is live.
pub(crate) async fn spawn() -> Result<Arc<Self>, ClientError> {
let bin = std::env::var(SIDECAR_BIN_ENV).unwrap_or_else(|_| "agent-os-sidecar".to_string());
pub(crate) async fn spawn(binary_path: Option<String>) -> Result<Arc<Self>, ClientError> {
// Prefer the typed path threaded from `AgentOsConfig` (resolved from the
// npm package on the TypeScript side), mirroring how rivetkit threads
// `engine_binary_path` into `Command::new`. The `AGENT_OS_SIDECAR_BIN`
// env var stays only as a debug/override fallback.
let bin = binary_path
.or_else(|| std::env::var(SIDECAR_BIN_ENV).ok())
.unwrap_or_else(|| "agent-os-sidecar".to_string());
let mut child = Command::new(&bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
Expand Down
17 changes: 16 additions & 1 deletion crates/kernel/assets/base-filesystem.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"snapshotPath": "alpine-defaults.json",
"image": "alpine:3.22",
"snapshotCreatedAt": "2026-04-01T18:44:12.482Z",
"builtAt": "2026-04-01T19:03:51.260Z",
"builtAt": "2026-06-12T21:26:11.555Z",
"transforms": [
"Normalize HOSTNAME to agent-os",
"Preserve the captured user-level environment and filesystem layout as the AgentOs base layer"
Expand Down Expand Up @@ -522,6 +522,21 @@
"mode": "1777",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos",
"type": "directory",
"mode": "755",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos/instructions.md",
"type": "file",
"mode": "644",
"uid": 0,
"gid": 0,
"content": "# agentOS\n\nYou are running inside agentOS, a Linux-like operating system for coding agents. \n\nKnown limitations:\n\n- Binaries inside agentOS are special. You cannot download and install arbitrary binaries (e.g. via curl or apt).\n- No file watching (inotify, fs.watch).\n\n## Bug Reports\n\nIf you find a bug in something that does not behave like you would expect in agentOS, generate a bug report and indicate the user should file it under: https://github.com/rivet-dev/rivet/issues (This repository holds agentOS inside of it.)\n\n## Tools CLI\n\nTools are available as CLI commands:\n\n- `agentos list-tools` — list all available toolkits and tools\n- `agentos-{toolkit} {tool} --help` — show usage for a specific tool\n- `agentos-{toolkit} {tool} --flag value` — invoke a tool\n\n"
}
]
}
Expand Down
17 changes: 16 additions & 1 deletion crates/sidecar/assets/base-filesystem.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"snapshotPath": "alpine-defaults.json",
"image": "alpine:3.22",
"snapshotCreatedAt": "2026-04-01T18:44:12.482Z",
"builtAt": "2026-04-01T19:03:51.260Z",
"builtAt": "2026-06-12T21:26:11.555Z",
"transforms": [
"Normalize HOSTNAME to agent-os",
"Preserve the captured user-level environment and filesystem layout as the AgentOs base layer"
Expand Down Expand Up @@ -522,6 +522,21 @@
"mode": "1777",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos",
"type": "directory",
"mode": "755",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos/instructions.md",
"type": "file",
"mode": "644",
"uid": 0,
"gid": 0,
"content": "# agentOS\n\nYou are running inside agentOS, a Linux-like operating system for coding agents. \n\nKnown limitations:\n\n- Binaries inside agentOS are special. You cannot download and install arbitrary binaries (e.g. via curl or apt).\n- No file watching (inotify, fs.watch).\n\n## Bug Reports\n\nIf you find a bug in something that does not behave like you would expect in agentOS, generate a bug report and indicate the user should file it under: https://github.com/rivet-dev/rivet/issues (This repository holds agentOS inside of it.)\n\n## Tools CLI\n\nTools are available as CLI commands:\n\n- `agentos list-tools` — list all available toolkits and tools\n- `agentos-{toolkit} {tool} --help` — show usage for a specific tool\n- `agentos-{toolkit} {tool} --flag value` — invoke a tool\n\n"
}
]
}
Expand Down
17 changes: 16 additions & 1 deletion packages/core/fixtures/base-filesystem.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"snapshotPath": "alpine-defaults.json",
"image": "alpine:3.22",
"snapshotCreatedAt": "2026-04-01T18:44:12.482Z",
"builtAt": "2026-04-01T19:03:51.260Z",
"builtAt": "2026-06-12T21:26:11.555Z",
"transforms": [
"Normalize HOSTNAME to agent-os",
"Preserve the captured user-level environment and filesystem layout as the AgentOs base layer"
Expand Down Expand Up @@ -522,6 +522,21 @@
"mode": "1777",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos",
"type": "directory",
"mode": "755",
"uid": 0,
"gid": 0
},
{
"path": "/etc/agentos/instructions.md",
"type": "file",
"mode": "644",
"uid": 0,
"gid": 0,
"content": "# agentOS\n\nYou are running inside agentOS, a Linux-like operating system for coding agents. \n\nKnown limitations:\n\n- Binaries inside agentOS are special. You cannot download and install arbitrary binaries (e.g. via curl or apt).\n- No file watching (inotify, fs.watch).\n\n## Bug Reports\n\nIf you find a bug in something that does not behave like you would expect in agentOS, generate a bug report and indicate the user should file it under: https://github.com/rivet-dev/rivet/issues (This repository holds agentOS inside of it.)\n\n## Tools CLI\n\nTools are available as CLI commands:\n\n- `agentos list-tools` — list all available toolkits and tools\n- `agentos-{toolkit} {tool} --help` — show usage for a specific tool\n- `agentos-{toolkit} {tool} --flag value` — invoke a tool\n\n"
}
]
}
Expand Down
28 changes: 27 additions & 1 deletion packages/core/scripts/build-base-filesystem.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const DEFAULT_INPUT = fileURLToPath(
const DEFAULT_OUTPUT = fileURLToPath(
new URL("../fixtures/base-filesystem.json", import.meta.url),
);
const OS_INSTRUCTIONS_FIXTURE = fileURLToPath(
new URL("../fixtures/AGENTOS_SYSTEM_PROMPT.md", import.meta.url),
);

const BASE_HOSTNAME = "agent-os";
const BASE_USER = "user";
Expand Down Expand Up @@ -53,11 +56,34 @@ function buildBaseFilesystem(snapshot, inputPath) {
prompt: snapshot.environment.prompt,
},
filesystem: {
entries: snapshot.filesystem.entries.map(normalizeEntry),
entries: [
...snapshot.filesystem.entries.map(normalizeEntry),
...osInstructionsEntries(),
],
},
};
}

// Bake the base agentOS system prompt into the snapshot so every VM has
// `/etc/agentos/instructions.md` by default. This is the single source for the
// prompt: the TS core, Rust client, and sidecar all consume the file from the
// VM rather than embedding their own copy. Session-level additional
// instructions are appended at session creation, not here.
function osInstructionsEntries() {
const content = readFileSync(OS_INSTRUCTIONS_FIXTURE, "utf-8");
return [
{ path: "/etc/agentos", type: "directory", mode: "755", uid: 0, gid: 0 },
{
path: "/etc/agentos/instructions.md",
type: "file",
mode: "644",
uid: 0,
gid: 0,
content,
},
];
}

function main() {
const [inputPath = DEFAULT_INPUT, outputPath = DEFAULT_OUTPUT] = process.argv.slice(2);
const snapshot = readJson(inputPath);
Expand Down
Loading