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
25 changes: 6 additions & 19 deletions packages/sidecar-binary/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,15 @@
// 2. A `agent-os-sidecar` binary placed next to this package (dev builds).
// 3. The platform-specific `@rivet-dev/agent-os-sidecar-<platform>` package.

const { existsSync, chmodSync, statSync } = require("node:fs");
const { existsSync } = require("node:fs");
const { join, dirname } = require("node:path");

const BINARY_NAME = "agent-os-sidecar";

// npm normalizes published non-`bin` file modes to 0644, stripping the
// executable bit from the platform binary. Restore it (best effort) so the
// resolved path is directly spawnable.
function ensureExecutable(binaryPath) {
try {
const mode = statSync(binaryPath).mode;
if ((mode & 0o111) === 0) {
chmodSync(binaryPath, mode | 0o755);
}
} catch {
// Best effort: a read-only install or unsupported platform should not
// break resolution. The spawn will surface a clear error if needed.
}
}
// No runtime chmod: the platform packages are published with `npm publish`,
// which preserves the binary's 0755 executable bit (pnpm publish would strip
// it to 0644). This mirrors how @rivetkit/engine-cli ships rivet-engine. See
// the "Native Binary Distribution" section in CLAUDE.md.

function getPlatformPackageName() {
const { platform, arch } = process;
Expand Down Expand Up @@ -56,7 +46,6 @@ function getSidecarPath() {

const localBinary = join(__dirname, BINARY_NAME);
if (existsSync(localBinary)) {
ensureExecutable(localBinary);
return localBinary;
}

Expand All @@ -81,9 +70,7 @@ function getSidecarPath() {
);
}

const binaryPath = join(dirname(pkgJsonPath), BINARY_NAME);
ensureExecutable(binaryPath);
return binaryPath;
return join(dirname(pkgJsonPath), BINARY_NAME);
}

module.exports = { getSidecarPath };
Loading