From 677c149345f5ed0a3fb823b52540877c3c7ec228 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 11:35:46 -0700 Subject: [PATCH] =?UTF-8?q?[SLOP(claude-opus-4-8-high)]=20chore(agent-os):?= =?UTF-8?q?=20match=20rivet-dev/rivet=20for=20native=20binary=20=E2=80=94?= =?UTF-8?q?=20npm-publish=20exec=20bit=20+=20typed-config=20spawn=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 23 ++++++++++++++++++----- CLAUDE.md | 6 ++++++ Cargo.lock | 14 +++++++------- crates/client/src/agent_os.rs | 6 ++++-- crates/client/src/config.rs | 10 ++++++++++ crates/client/src/sidecar.rs | 12 ++++++++++-- crates/client/src/transport.rs | 10 ++++++++-- packages/sidecar-binary/index.js | 25 ++++++------------------- 8 files changed, 69 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a220ab81..5c357df39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,8 +148,14 @@ 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") @@ -157,15 +163,22 @@ jobs: 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 diff --git a/CLAUDE.md b/CLAUDE.md index 250b20bec..915f5c874 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-`) 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: diff --git a/Cargo.lock b/Cargo.lock index 93aa4a9dc..ab9ed6146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-os-bridge" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "serde", "serde_json", @@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "agent-os-client" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "agent-os-sidecar", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "agent-os-execution" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "agent-os-v8-runtime", @@ -67,7 +67,7 @@ dependencies = [ [[package]] name = "agent-os-kernel" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "base64 0.22.1", @@ -80,7 +80,7 @@ dependencies = [ [[package]] name = "agent-os-sidecar" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "agent-os-execution", @@ -122,7 +122,7 @@ dependencies = [ [[package]] name = "agent-os-sidecar-browser" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "agent-os-kernel", @@ -130,7 +130,7 @@ dependencies = [ [[package]] name = "agent-os-v8-runtime" -version = "0.1.0" +version = "0.2.0-rc.3" dependencies = [ "agent-os-bridge", "ciborium", diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index c0301345a..f4fe0d402 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -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?; diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index df86117f4..5fb6fe4e6 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -40,6 +40,11 @@ pub struct AgentOsConfig { pub permissions: Option, /// Sidecar placement/config. Default: shared `default` pool. pub sidecar: Option, + /// 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, } /// Builder for [`AgentOsConfig`]. @@ -108,6 +113,11 @@ impl AgentOsConfigBuilder { self } + pub fn sidecar_binary_path(mut self, path: impl Into) -> Self { + self.config.sidecar_binary_path = Some(path.into()); + self + } + pub fn build(self) -> AgentOsConfig { self.config } diff --git a/crates/client/src/sidecar.rs b/crates/client/src/sidecar.rs index aa93c7f8a..c57cb97e8 100644 --- a/crates/client/src/sidecar.rs +++ b/crates/client/src/sidecar.rs @@ -110,6 +110,10 @@ pub struct AgentOsSidecar { pub(crate) shared_pool: Option, 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, /// 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>, @@ -121,6 +125,7 @@ impl AgentOsSidecar { sidecar_id: impl Into, placement: AgentOsSidecarPlacement, shared_pool: Option, + sidecar_binary_path: Option, ) -> Self { Self { sidecar_id: sidecar_id.into(), @@ -128,6 +133,7 @@ impl AgentOsSidecar { shared_pool, state: AtomicU8::new(SidecarState::Ready.as_u8()), active_vm_count: AtomicU32::new(0), + sidecar_binary_path, connection: tokio::sync::Mutex::new(None), } } @@ -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"), @@ -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 @@ -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, + sidecar_binary_path: Option, ) -> Result, ClientError> { let pool = pool.unwrap_or_else(|| "default".to_string()); let cache = shared_sidecars(); @@ -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 diff --git a/crates/client/src/transport.rs b/crates/client/src/transport.rs index 2a16e57ed..42d16e3b4 100644 --- a/crates/client/src/transport.rs +++ b/crates/client/src/transport.rs @@ -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, 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) -> Result, 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()) diff --git a/packages/sidecar-binary/index.js b/packages/sidecar-binary/index.js index c1e2540c3..128861a8c 100644 --- a/packages/sidecar-binary/index.js +++ b/packages/sidecar-binary/index.js @@ -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-` 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; @@ -56,7 +46,6 @@ function getSidecarPath() { const localBinary = join(__dirname, BINARY_NAME); if (existsSync(localBinary)) { - ensureExecutable(localBinary); return localBinary; } @@ -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 };