diff --git a/.github/refs/secure-exec b/.github/refs/secure-exec index 00c7a6bfb..3e061c4c4 100644 --- a/.github/refs/secure-exec +++ b/.github/refs/secure-exec @@ -1 +1 @@ -abf0d0172b05e59dfc8bbb698ae73904b2a076b7 +78efd873e92b917b2bf2dc08cf1f347a44f2ca10 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index c4c5746f2..7f6c35c30 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -117,7 +117,7 @@ jobs: fi case "$v" in 0.0.0-*) echo "::error::secure_exec_version must be a real release, not a preview"; exit 1;; esac npm view "@secure-exec/core@$v" version >/dev/null - curl -sf "https://crates.io/api/v1/crates/secure-exec-sidecar/$v" >/dev/null || { echo "::error::secure-exec-sidecar@$v is not on crates.io"; exit 1; } + curl -sf "https://index.crates.io/se/cu/secure-exec-sidecar" | grep -q "\"vers\":\"$v\"" || { echo "::error::secure-exec-sidecar@$v is not on crates.io"; exit 1; } echo "version=$v" >> "$GITHUB_OUTPUT" echo "registry_tag=latest" >> "$GITHUB_OUTPUT" exit 0 @@ -404,11 +404,14 @@ jobs: pnpm --filter=publish exec tsx src/ci/bin.ts bump-versions \ --version ${{ needs.context.outputs.version }} \ --version-only + - name: Install wasm-pack (for @rivet-dev/agentos-browser wasm build) + run: | + rustup target add wasm32-unknown-unknown + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Build TypeScript packages run: | npx turbo build \ --filter='!@rivet-dev/agentos-playground' \ - --filter='!@agentos/website' \ --filter='!./examples/*' \ --filter='!./examples/quickstart/*' - name: Finalize package versions for publish (inject optionalDeps) diff --git a/CLAUDE.md b/CLAUDE.md index 6a6cadbc1..376ea2dc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,9 @@ Every limit, timeout, bounded queue/buffer, and per-entity collection MUST be bo - Subscription methods are delivered through actor events; lifecycle behavior belongs in actor sleep/destroy hooks. - Agent adapters must use real upstream agent SDKs. Do not replace SDK adapters with direct API-call stubs. - Host-native agent wrappers are not allowed; agents run through the VM runtime supplied by secure-exec. +- **The client (TS `packages/core` AND Rust `crates/client`) is npm-agnostic.** It only ever knows the tar/dir paths handed to it via the API (`software`/`linkSoftware`/the actor's `packages`). It MUST NOT scan `node_modules`, call `require.resolve`/`createRequire`, read its own `package.json` dependencies, or compute/parse an agent's adapter entrypoint. npm is a *delivery* mechanism only: each `@agentos-software/*` package self-resolves its own tar/dir path (e.g. `import common from "@agentos-software/common"`), and the client forwards `{dir}` on the wire. Default software is a plain import (`resolveDefaultSoftware()` returns `[common].flat()`), not a dependency scan. +- **Agent resolution AND enumeration are OWNED BY THE SIDECAR.** `createSession(name)`/`resumeSession(name)` send only the agent `agentType` (no `adapterEntrypoint` on the wire); the sidecar reads the projected `/opt/agentos//current/agentos-package.json` and resolves `entrypoint = /opt/agentos/bin/` plus the manifest's `env` (applied as defaults; caller env wins) and `launchArgs` (prepended before caller args), then spawns. `listAgents()` is a sidecar ACP RPC (`AcpListAgentsRequest`) — the sidecar enumerates the projected `/opt/agentos` packages; the client parses NO manifests for discovery. There is no hardcoded `AGENT_CONFIGS`/`BUILTIN_AGENT_IDS` table and no `/root/node_modules/` npm lookup. +- **The Rust (`crates/client`) and TypeScript (`packages/core`) clients MUST behave IDENTICALLY.** They are two implementations of the same client surface; any change to one must be mirrored in the other in the same change. Both send only `agentType`; there is NO client divergence (the old TS-only lazy-link/`resolveDependencyAgents()` Node-module path is removed). The public method surface and wire behavior must match exactly. ## Extension Authoring diff --git a/crates/agentos-actor-plugin/src/config.rs b/crates/agentos-actor-plugin/src/config.rs index 301190403..08ef7beb9 100644 --- a/crates/agentos-actor-plugin/src/config.rs +++ b/crates/agentos-actor-plugin/src/config.rs @@ -43,8 +43,6 @@ pub(crate) struct AgentOsConfigJson { #[serde(default)] additional_instructions: Option, #[serde(default)] - module_access_cwd: Option, - #[serde(default)] loopback_exempt_ports: Vec, #[serde(default)] allowed_node_builtins: Option>, @@ -160,7 +158,6 @@ impl AgentOsConfigJson { packages_mount_at: self.packages_mount_at.clone(), loopback_exempt_ports: self.loopback_exempt_ports.clone(), allowed_node_builtins: self.allowed_node_builtins.clone(), - module_access_cwd: self.module_access_cwd.clone(), additional_instructions: self.additional_instructions.clone(), permissions: self.permissions.clone(), mounts: self diff --git a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare index 68388e87e..830a052ed 100644 --- a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare +++ b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare @@ -12,7 +12,6 @@ type AcpRuntimeKind enum { type AcpCreateSessionRequest struct { agentType: str runtime: AcpRuntimeKind - adapterEntrypoint: str cwd: str args: list env: map @@ -29,6 +28,21 @@ type AcpSessionRequest struct { params: optional } +# Enumerate the agents available in this VM. The sidecar answers from the already +# projected `/opt/agentos` packages (client parses no manifests). +type AcpListAgentsRequest struct { + reserved: bool +} + +type AcpAgentEntry struct { + id: str + installed: bool +} + +type AcpListAgentsResponse struct { + agents: list +} + type AcpGetSessionStateRequest struct { sessionId: str } @@ -69,7 +83,8 @@ type AcpRequest union { AcpGetSessionStateRequest | AcpCloseSessionRequest | AcpResumeSessionRequest | - AcpDeliverAgentOutputRequest + AcpDeliverAgentOutputRequest | + AcpListAgentsRequest } type AcpSessionCreatedResponse struct { @@ -134,7 +149,8 @@ type AcpResponse union { AcpSessionClosedResponse | AcpSessionResumedResponse | AcpErrorResponse | - AcpPendingResponse + AcpPendingResponse | + AcpListAgentsResponse } type AcpSessionEvent struct { diff --git a/crates/agentos-protocol/tests/roundtrip.rs b/crates/agentos-protocol/tests/roundtrip.rs index bece8528b..13ef10425 100644 --- a/crates/agentos-protocol/tests/roundtrip.rs +++ b/crates/agentos-protocol/tests/roundtrip.rs @@ -7,7 +7,6 @@ fn acp_protocol_round_trips_create_session() { let request = AcpRequest::AcpCreateSessionRequest(AcpCreateSessionRequest { agent_type: String::from("codex"), runtime: AcpRuntimeKind::JavaScript, - adapter_entrypoint: String::from("/root/node_modules/agent/adapter.mjs"), cwd: String::from("/home/agentos"), args: vec![String::from("--model"), String::from("gpt-5")], env: [( diff --git a/crates/agentos-sidecar-core/src/engine.rs b/crates/agentos-sidecar-core/src/engine.rs index 1ee8273a5..7c936a276 100644 --- a/crates/agentos-sidecar-core/src/engine.rs +++ b/crates/agentos-sidecar-core/src/engine.rs @@ -31,10 +31,6 @@ const SESSION_CLOSE_TIMEOUT_MS: u64 = 5_000; const INITIALIZE_TIMEOUT_MS: u64 = 10_000; const SESSION_NEW_TIMEOUT_MS: u64 = 30_000; -/// Reserved `env` key on `AcpResumeSessionRequest` carrying the adapter entrypoint -/// (the resume wire request stays minimal; the caller forwards it through env). -/// Matches the native `RESUME_ADAPTER_ENTRYPOINT_ENV`. -const RESUME_ADAPTER_ENTRYPOINT_ENV: &str = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; /// Matches the native `ACP_RESUME_PROTOCOL_VERSION`. const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; /// Matches the native `DEFAULT_RESUME_CLIENT_CAPABILITIES`. @@ -43,6 +39,62 @@ const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = "{}"; /// native `CONTINUATION_PREAMBLE`. const CONTINUATION_PREAMBLE: &str = "You are continuing an earlier session. The full prior transcript is at `{path}`. Read it with your file tools if you need context before answering."; +/// Agent launch parameters resolved from a projected `/opt/agentos` package +/// manifest. The npm-agnostic client sends only the agent name; the sidecar owns +/// the name -> package -> entrypoint/env/launchArgs resolution. Mirrors the native +/// sidecar's `ResolvedAgent`. +struct ResolvedAgent { + entrypoint: String, + env: BTreeMap, + launch_args: Vec, +} + +/// The subset of `agentos-package.json` the sidecar needs to launch an agent. +#[derive(serde::Deserialize)] +struct AgentPackageManifest { + #[serde(default)] + agent: Option, +} + +#[derive(serde::Deserialize)] +struct AgentPackageAgentBlock { + #[serde(rename = "acpEntrypoint", default)] + acp_entrypoint: String, + #[serde(default)] + env: BTreeMap, + #[serde(rename = "launchArgs", default)] + launch_args: Vec, +} + +/// Resolve an agent name to its launch parameters by reading the projected +/// manifest at `/opt/agentos//current/agentos-package.json` over the host +/// filesystem seam. A missing file, a missing `agent` block, or an empty +/// `agent.acpEntrypoint` all map to a single typed "unknown agent" error. Mirrors +/// the native sidecar `resolve_agent`. +fn resolve_agent( + host: &mut H, + agent_type: &str, +) -> Result { + let unknown = || { + AcpCoreError::InvalidState(format!( + "unknown agent type \"{agent_type}\": no projected /opt/agentos/{agent_type} package \ + with an agent.acpEntrypoint — pass its package to AgentOs software" + )) + }; + let path = format!("/opt/agentos/{agent_type}/current/agentos-package.json"); + let bytes = host.read_file(&path).map_err(|_| unknown())?; + let manifest: AgentPackageManifest = serde_json::from_slice(&bytes).map_err(|_| unknown())?; + let agent = manifest.agent.ok_or_else(|| unknown())?; + if agent.acp_entrypoint.is_empty() { + return Err(unknown()); + } + Ok(ResolvedAgent { + entrypoint: format!("/opt/agentos/bin/{}", agent.acp_entrypoint), + env: agent.env, + launch_args: agent.launch_args, + }) +} + /// Host-free ACP session engine. The native and browser sidecars each hold one of /// these and feed it decoded requests plus the caller's connection id (for the /// per-connection ownership checks) and an [`AcpHost`] for the host-coupled steps. @@ -200,16 +252,24 @@ impl AcpCore { caller_connection_id: &str, request: &AcpCreateSessionRequest, ) -> Result { + let resolved = resolve_agent(host, &request.agent_type)?; let process_id = self.allocate_process_id("acp-agent"); let mut env: BTreeMap = request.env.clone().into_iter().collect(); env.insert(String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), String::from("1")); + // Manifest env applies as DEFAULTS; caller/base env wins on conflicts. + for (key, value) in &resolved.env { + env.entry(key.clone()).or_insert_with(|| value.clone()); + } + // Manifest launch args first, then any caller-supplied args. + let mut args = resolved.launch_args.clone(); + args.extend(request.args.iter().cloned()); let spawned = host.spawn_agent(SpawnAgentRequest { process_id: process_id.clone(), runtime: request.runtime.clone(), - entrypoint: Some(request.adapter_entrypoint.clone()), + entrypoint: Some(resolved.entrypoint.clone()), command: None, - args: request.args.clone(), + args, env, cwd: Some(request.cwd.clone()), })?; @@ -257,16 +317,24 @@ impl AcpCore { caller_connection_id: &str, request: &AcpCreateSessionRequest, ) -> Result { + let resolved = resolve_agent(host, &request.agent_type)?; let process_id = self.allocate_process_id("acp-agent"); let mut env: BTreeMap = request.env.clone().into_iter().collect(); env.insert(String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), String::from("1")); + // Manifest env applies as DEFAULTS; caller/base env wins on conflicts. + for (key, value) in &resolved.env { + env.entry(key.clone()).or_insert_with(|| value.clone()); + } + // Manifest launch args first, then any caller-supplied args. + let mut args = resolved.launch_args.clone(); + args.extend(request.args.iter().cloned()); let spawned = host.spawn_agent(SpawnAgentRequest { process_id: process_id.clone(), runtime: request.runtime.clone(), - entrypoint: Some(request.adapter_entrypoint.clone()), + entrypoint: Some(resolved.entrypoint.clone()), command: None, - args: request.args.clone(), + args, env, cwd: Some(request.cwd.clone()), })?; @@ -671,27 +739,22 @@ impl AcpCore { caller_connection_id: &str, request: &AcpResumeSessionRequest, ) -> Result { - let adapter_entrypoint = request - .env - .get(RESUME_ADAPTER_ENTRYPOINT_ENV) - .cloned() - .ok_or_else(|| { - AcpCoreError::InvalidState(format!( - "resume request missing reserved env `{RESUME_ADAPTER_ENTRYPOINT_ENV}` (adapter entrypoint)" - )) - })?; + let resolved = resolve_agent(host, &request.agent_type)?; let process_id = self.allocate_process_id("acp-agent"); let mut env: BTreeMap = request.env.clone().into_iter().collect(); - env.remove(RESUME_ADAPTER_ENTRYPOINT_ENV); env.insert(String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), String::from("1")); + // Manifest env applies as DEFAULTS; caller/base env wins on conflicts. + for (key, value) in &resolved.env { + env.entry(key.clone()).or_insert_with(|| value.clone()); + } let spawned = host.spawn_agent(SpawnAgentRequest { process_id: process_id.clone(), runtime: AcpRuntimeKind::JavaScript, - entrypoint: Some(adapter_entrypoint), + entrypoint: Some(resolved.entrypoint.clone()), command: None, - args: Vec::new(), + args: resolved.launch_args.clone(), env, cwd: Some(request.cwd.clone()), })?; @@ -874,6 +937,15 @@ impl AcpCore { AcpRequest::AcpDeliverAgentOutputRequest(request) => { self.deliver_agent_output(host, &request) } + // Agent enumeration needs a directory listing of `/opt/agentos`, which + // the host-free `AcpHost` seam does not expose (it has `read_file`, not + // `read_dir`). The native sidecar answers `list_agents` directly from the + // projected packages; the browser resumable path does not support it yet. + AcpRequest::AcpListAgentsRequest(_) => Err(AcpCoreError::InvalidState( + "list_agents is not handled by the host-free ACP core; the native sidecar answers \ + it from the projected /opt/agentos packages" + .to_string(), + )), } } @@ -1157,7 +1229,7 @@ mod tests { Ok(()) } fn read_file(&mut self, _: &str) -> Result, AcpCoreError> { - Ok(Vec::new()) + Ok(br#"{"name":"echo","agent":{"acpEntrypoint":"echo-agent"}}"#.to_vec()) } fn now_ms(&self) -> u64 { 0 @@ -1284,7 +1356,7 @@ mod tests { Ok(()) } fn read_file(&mut self, _: &str) -> Result, AcpCoreError> { - Ok(Vec::new()) + Ok(br#"{"name":"echo","agent":{"acpEntrypoint":"echo-agent"}}"#.to_vec()) } fn now_ms(&self) -> u64 { self.clock @@ -1375,7 +1447,7 @@ mod tests { Ok(()) } fn read_file(&mut self, _: &str) -> Result, AcpCoreError> { - Ok(Vec::new()) + Ok(br#"{"name":"echo","agent":{"acpEntrypoint":"echo-agent"}}"#.to_vec()) } fn now_ms(&self) -> u64 { self.clock @@ -1384,17 +1456,12 @@ mod tests { let mut core = AcpCore::new(); let mut host = ResumeHost::default(); - let mut env = HashMap::new(); - env.insert( - RESUME_ADAPTER_ENTRYPOINT_ENV.to_string(), - "/bin/echo-agent".to_string(), - ); let request = AcpResumeSessionRequest { session_id: "old-session".into(), agent_type: "echo".into(), transcript_path: Some("/transcripts/old.jsonl".into()), cwd: "/workspace".into(), - env, + env: HashMap::new(), }; let response = core @@ -1477,7 +1544,7 @@ mod tests { Ok(()) } fn read_file(&mut self, _: &str) -> Result, AcpCoreError> { - Ok(Vec::new()) + Ok(br#"{"name":"echo","agent":{"acpEntrypoint":"echo-agent"}}"#.to_vec()) } fn now_ms(&self) -> u64 { self.clock @@ -1488,7 +1555,6 @@ mod tests { let mut host = CreateHost::default(); let request = AcpCreateSessionRequest { agent_type: "echo".into(), - adapter_entrypoint: "/bin/echo-agent".into(), runtime: AcpRuntimeKind::JavaScript, protocol_version: 1, cwd: "/workspace".into(), @@ -1570,7 +1636,7 @@ mod tests { Ok(()) } fn read_file(&mut self, _: &str) -> Result, AcpCoreError> { - Ok(Vec::new()) + Ok(br#"{"name":"echo","agent":{"acpEntrypoint":"echo-agent"}}"#.to_vec()) } fn now_ms(&self) -> u64 { 0 @@ -1580,7 +1646,6 @@ mod tests { fn echo_create_request() -> AcpCreateSessionRequest { AcpCreateSessionRequest { agent_type: "echo".into(), - adapter_entrypoint: "/bin/echo-agent".into(), runtime: AcpRuntimeKind::JavaScript, protocol_version: 1, cwd: "/workspace".into(), diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index f57ffb051..e9d46888c 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -5,10 +5,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use agentos_protocol::generated::v1::{ - AcpAgentExitedEvent, AcpAgentStderrEvent, AcpCallback, AcpCallbackResponse, + AcpAgentEntry, AcpAgentExitedEvent, AcpAgentStderrEvent, AcpCallback, AcpCallbackResponse, AcpCloseSessionRequest, AcpCreateSessionRequest, AcpErrorResponse, AcpEvent, AcpGetSessionStateRequest, - AcpHostRequestCallback, AcpPermissionCallback, AcpRequest, AcpResponse, + AcpHostRequestCallback, AcpListAgentsResponse, AcpPermissionCallback, AcpRequest, AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind, AcpSessionClosedResponse, AcpSessionCreatedResponse, AcpSessionEvent, AcpSessionRequest, AcpSessionResumedResponse, AcpSessionStateResponse, }; @@ -41,12 +41,6 @@ const ACP_CANCEL_METHOD: &str = "session/cancel"; /// the rendered transcript and reads it on demand with its own file tools. `{path}` /// is substituted with the guest-readable transcript path. Tunable; see spec §6. const CONTINUATION_PREAMBLE: &str = "You are continuing an earlier session. The full prior transcript is at `{path}`. Read it with your file tools if you need context before answering."; -/// Reserved `env` key on `AcpResumeSessionRequest` carrying the adapter bin -/// entrypoint. The resume wire request intentionally omits a dedicated -/// `adapterEntrypoint` field; the thin client resolves it exactly as it does for -/// create and forwards it through `env` under this key so the sidecar still owns -/// the launch. Stripped before the adapter process env is assembled. -const RESUME_ADAPTER_ENTRYPOINT_ENV: &str = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; const ACP_TRACE_PATH_ENV: &str = "AGENT_OS_ACP_TRACE_PATH"; /// ACP protocol version used for the resume handshake. Lockstep single version. const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; @@ -214,6 +208,7 @@ impl AcpExtension { AcpRequest::AcpResumeSessionRequest(request) => { self.resume_session(ctx, request).await } + AcpRequest::AcpListAgentsRequest(_) => self.list_agents(ctx).await, AcpRequest::AcpDeliverAgentOutputRequest(_) => AcpHandlerOutput::response(Err( SidecarError::InvalidState( "AcpDeliverAgentOutputRequest is dispatched by the engine/browser resumable path, not the native ACP extension".to_string(), @@ -263,6 +258,7 @@ impl AcpExtension { AcpRequest::AcpCloseSessionRequest(_) => "close_session", AcpRequest::AcpSessionRequest(_) => "session_request", AcpRequest::AcpResumeSessionRequest(_) => "resume_session", + AcpRequest::AcpListAgentsRequest(_) => "list_agents", AcpRequest::AcpDeliverAgentOutputRequest(_) => "deliver_agent_output", } } @@ -273,13 +269,26 @@ impl AcpExtension { request: AcpCreateSessionRequest, ) -> AcpHandlerOutput { let __t0 = Instant::now(); + // Resolve the agent name -> package entrypoint/env/launchArgs from the + // projected `/opt/agentos//current/agentos-package.json`. The client + // is npm-agnostic and sends only the agent name; the sidecar owns this. + let resolved = match resolve_agent(&mut ctx, &request.agent_type).await { + Ok(resolved) => resolved, + Err(error) => return AcpHandlerOutput::response(Err(error)), + }; let process_id = self.allocate_process_id("acp-agent"); - let mut args = request.args.clone(); + // Manifest launch args first, then any caller-supplied args. + let mut args = resolved.launch_args.clone(); + args.extend(request.args.iter().cloned()); let mut env = hash_to_btree(request.env.clone()); env.insert( String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), String::from("1"), ); + // Manifest env applies as DEFAULTS; caller/base env wins on conflicts. + for (key, value) in &resolved.env { + env.entry(key.clone()).or_insert_with(|| value.clone()); + } if let Err(error) = self .apply_prompt_injection(&mut ctx, &request, &mut args, &mut env) .await @@ -292,7 +301,7 @@ impl AcpExtension { // auto-restart before they are moved into the spawn request. let restart_state = AdapterRestartState { runtime: request.runtime.clone(), - entrypoint: request.adapter_entrypoint.clone(), + entrypoint: resolved.entrypoint.clone(), args: args.clone(), env: env.clone(), cwd: request.cwd.clone(), @@ -306,7 +315,7 @@ impl AcpExtension { process_id: process_id.clone(), command: None, runtime: Some(convert_runtime(request.runtime.clone())), - entrypoint: Some(request.adapter_entrypoint.clone()), + entrypoint: Some(resolved.entrypoint.clone()), args, env: env.into_iter().collect(), cwd: Some(request.cwd.clone()), @@ -390,6 +399,57 @@ impl AcpExtension { } } + /// Enumerate the agents available in this VM from the ALREADY-PROJECTED + /// `/opt/agentos` packages. Lists `/opt/agentos`, skips the `bin` symlink farm, + /// and for each package dir reads `/current/agentos-package.json`; a dir + /// whose manifest carries a non-empty `agent.acpEntrypoint` is an agent. The + /// client parses no manifests — the sidecar owns agent enumeration too. Sorted + /// by id. + async fn list_agents(&self, mut ctx: ExtensionContext<'_>) -> AcpHandlerOutput { + let listing = ctx + .guest_filesystem_call_wire(GuestFilesystemCallRequest { + operation: GuestFilesystemOperation::ReadDir, + path: String::from("/opt/agentos"), + destination_path: None, + target: None, + content: None, + encoding: None, + recursive: false, + mode: None, + uid: None, + gid: None, + atime_ms: None, + mtime_ms: None, + len: None, + offset: None, + }) + .await; + // No projection (e.g. no packages) => no agents. + let entries = match listing { + Ok(result) => result.entries.unwrap_or_default(), + Err(_) => Vec::new(), + }; + let mut agents = Vec::new(); + for entry in entries { + if entry.name == "bin" { + continue; + } + if read_projected_agent_block(&mut ctx, &entry.name) + .await + .is_some() + { + agents.push(AcpAgentEntry { + id: entry.name, + installed: true, + }); + } + } + agents.sort_by(|a, b| a.id.cmp(&b.id)); + AcpHandlerOutput::response(Ok(AcpResponse::AcpListAgentsResponse( + AcpListAgentsResponse { agents }, + ))) + } + async fn create_session_inner( &self, ctx: &mut ExtensionContext<'_>, @@ -873,6 +933,14 @@ impl AcpExtension { mut ctx: ExtensionContext<'_>, request: AcpResumeSessionRequest, ) -> AcpHandlerOutput { + // Resolve the agent name -> package entrypoint/env/launchArgs from the + // projected manifest, exactly as create_session does. The client is + // npm-agnostic and sends only the agent name. + let resolved = match resolve_agent(&mut ctx, &request.agent_type).await { + Ok(resolved) => resolved, + Err(error) => return AcpHandlerOutput::response(Err(error)), + }; + // Reconstruct a create-shaped request so we reuse the exact adapter launch // + initialize flow. Resume does not carry MCP servers or extra instructions // (the durable transcript, not re-injected instructions, carries context); @@ -881,25 +949,9 @@ impl AcpExtension { let create_like = AcpCreateSessionRequest { agent_type: request.agent_type.clone(), runtime: AcpRuntimeKind::JavaScript, - // The resume request does not carry the adapter entrypoint; the caller - // resolves it the same way create does and forwards it through `env` - // under the reserved key below. This keeps the resume wire request - // minimal while letting the sidecar own the launch. - adapter_entrypoint: match request.env.get(RESUME_ADAPTER_ENTRYPOINT_ENV) { - Some(entrypoint) => entrypoint.clone(), - None => { - return AcpHandlerOutput::response(Err(SidecarError::InvalidState(format!( - "resume request missing reserved env `{RESUME_ADAPTER_ENTRYPOINT_ENV}` (adapter entrypoint)" - )))); - } - }, cwd: request.cwd.clone(), args: Vec::new(), - env: { - let mut env = request.env.clone(); - env.remove(RESUME_ADAPTER_ENTRYPOINT_ENV); - env - }, + env: request.env.clone(), protocol_version: ACP_RESUME_PROTOCOL_VERSION, client_capabilities: DEFAULT_RESUME_CLIENT_CAPABILITIES.to_string(), mcp_servers: "[]".to_string(), @@ -908,12 +960,18 @@ impl AcpExtension { }; let process_id = self.allocate_process_id("acp-agent"); - let mut args = create_like.args.clone(); + // Manifest launch args first, then any caller-supplied args. + let mut args = resolved.launch_args.clone(); + args.extend(create_like.args.iter().cloned()); let mut env = hash_to_btree(create_like.env.clone()); env.insert( String::from("SECURE_EXEC_KEEP_STDIN_OPEN"), String::from("1"), ); + // Manifest env applies as DEFAULTS; caller/base env wins on conflicts. + for (key, value) in &resolved.env { + env.entry(key.clone()).or_insert_with(|| value.clone()); + } if let Err(error) = self .apply_prompt_injection(&mut ctx, &create_like, &mut args, &mut env) .await @@ -925,7 +983,7 @@ impl AcpExtension { // auto-restart before they are moved into the spawn request. let restart_state = AdapterRestartState { runtime: create_like.runtime.clone(), - entrypoint: create_like.adapter_entrypoint.clone(), + entrypoint: resolved.entrypoint.clone(), args: args.clone(), env: env.clone(), cwd: create_like.cwd.clone(), @@ -939,7 +997,7 @@ impl AcpExtension { process_id: process_id.clone(), command: None, runtime: Some(convert_runtime(create_like.runtime.clone())), - entrypoint: Some(create_like.adapter_entrypoint.clone()), + entrypoint: Some(resolved.entrypoint.clone()), args, env: env.into_iter().collect(), cwd: Some(create_like.cwd.clone()), @@ -1496,6 +1554,7 @@ impl Extension for AcpExtension { | AcpRequest::AcpCloseSessionRequest(_) | AcpRequest::AcpResumeSessionRequest(_) | AcpRequest::AcpSessionRequest(_) + | AcpRequest::AcpListAgentsRequest(_) | AcpRequest::AcpDeliverAgentOutputRequest(_) => None, } } @@ -2400,6 +2459,106 @@ fn hash_to_btree(map: HashMap) -> BTreeMap { map.into_iter().collect() } +/// The agent launch parameters resolved from a projected `/opt/agentos` package +/// manifest. The npm-agnostic client sends only the agent name; the sidecar owns +/// this name -> package -> entrypoint/env/launchArgs resolution. +struct ResolvedAgent { + entrypoint: String, + env: BTreeMap, + launch_args: Vec, +} + +/// The `agent` block of an `agentos-package.json`, parsed from its JSON value: a +/// non-empty `acpEntrypoint` plus optional launch env/args. +struct AgentPackageAgentBlock { + acp_entrypoint: String, + env: BTreeMap, + launch_args: Vec, +} + +/// Read the projected manifest at `/opt/agentos//current/agentos-package.json` +/// from the guest filesystem and return its `agent` block iff it exists and carries +/// a non-empty `acpEntrypoint`. A missing/unreadable/malformed manifest, a missing +/// `agent` block, or an empty `acpEntrypoint` all yield `None`. +async fn read_projected_agent_block( + ctx: &mut ExtensionContext<'_>, + agent_type: &str, +) -> Option { + let result = ctx + .guest_filesystem_call_wire(GuestFilesystemCallRequest { + operation: GuestFilesystemOperation::ReadFile, + path: format!("/opt/agentos/{agent_type}/current/agentos-package.json"), + destination_path: None, + target: None, + content: None, + encoding: None, + recursive: false, + mode: None, + uid: None, + gid: None, + atime_ms: None, + mtime_ms: None, + len: None, + offset: None, + }) + .await + .ok()?; + let text = result.content?; + let manifest: Value = serde_json::from_str(&text).ok()?; + let agent = manifest.get("agent")?; + let acp_entrypoint = agent.get("acpEntrypoint")?.as_str()?.to_string(); + if acp_entrypoint.is_empty() { + return None; + } + // Optional manifest launch env/args (defaults empty). + let env = agent + .get("env") + .and_then(Value::as_object) + .map(|map| { + map.iter() + .filter_map(|(key, value)| { + value.as_str().map(|value| (key.clone(), value.to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let launch_args = agent + .get("launchArgs") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(AgentPackageAgentBlock { + acp_entrypoint, + env, + launch_args, + }) +} + +/// Resolve an agent name to its launch parameters from the projected manifest. A +/// missing file, a missing `agent` block, or an empty `agent.acpEntrypoint` all map +/// to a single typed "unknown agent" error naming the agent and how to fix it. +async fn resolve_agent( + ctx: &mut ExtensionContext<'_>, + agent_type: &str, +) -> Result { + match read_projected_agent_block(ctx, agent_type).await { + Some(agent) => Ok(ResolvedAgent { + entrypoint: format!("/opt/agentos/bin/{}", agent.acp_entrypoint), + env: agent.env, + launch_args: agent.launch_args, + }), + None => Err(SidecarError::InvalidState(format!( + "unknown agent type \"{agent_type}\": no projected /opt/agentos/{agent_type} package \ + with an agent.acpEntrypoint — pass its package to AgentOs software" + ))), + } +} + /// Extract the owning connection id from an ownership scope. Every scope carries /// a connection id, which is the tenant boundary secure-exec enforces; ACP /// session ownership is keyed off this same connection id. diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index ff7380778..a07051a59 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -174,9 +174,8 @@ pub(crate) struct AgentOsInner { pub(crate) session_id: String, pub(crate) vm_id: String, pub(crate) request_counter: AtomicI64, - /// Command names linked at runtime via `link_software` (the sidecar owns the - /// `/opt/agentos` staging dir; this just tracks what we've asked it to link). - pub(crate) linked_commands: parking_lot::Mutex>, + /// Projected command names and guest entrypoints reported by the sidecar. + pub(crate) projected_commands: parking_lot::Mutex>, // Process registries. pub(crate) process_registry_lock: parking_lot::Mutex<()>, @@ -303,6 +302,8 @@ impl AgentOs { | wire::ResponsePayload::PersistenceFlushedResponse(_) | wire::ResponsePayload::VmFetchResponse(_) | wire::ResponsePayload::ExtEnvelope(_) + | wire::ResponsePayload::GuestKernelResultResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::PackageLinkedResponse(_) => { return Err(ClientError::Sidecar( "unexpected open_session response".to_string(), @@ -369,6 +370,8 @@ impl AgentOs { | wire::ResponsePayload::PersistenceFlushedResponse(_) | wire::ResponsePayload::VmFetchResponse(_) | wire::ResponsePayload::ExtEnvelope(_) + | wire::ResponsePayload::GuestKernelResultResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::PackageLinkedResponse(_) => { return Err(ClientError::Sidecar( "unexpected create_vm response".to_string(), @@ -397,7 +400,7 @@ impl AgentOs { // 6. Configure the VM (vm scope). The sidecar owns the `/opt/agentos` package // projection: it builds the staging dir + registers the read-only host_dir // mount itself from the forwarded `packages`. - match transport + let projected_commands = match transport .request_wire( wire_vm_ownership(&connection_id, &session_id, &vm_id), wire::RequestPayload::ConfigureVmRequest(wire::ConfigureVmRequest { @@ -406,18 +409,27 @@ impl AgentOs { // retired: all boot software is projected via `packages`. software: Vec::new(), permissions: Some(permissions), - module_access_cwd: config.module_access_cwd.clone(), + // Client-side `moduleAccessCwd` was removed in favor of an + // explicit `nodeModulesMount(...)` entry in `mounts`; the + // secure-exec wire field is left unset. + module_access_cwd: None, instructions: config.additional_instructions.clone().into_iter().collect(), projected_modules: Vec::new(), command_permissions: HashMap::new(), loopback_exempt_ports: config.loopback_exempt_ports.clone(), packages, packages_mount_at: config.packages_mount_at.clone().unwrap_or_default(), + bootstrap_commands: Vec::new(), + tool_shim_commands: Vec::new(), }), ) .await? { - wire::ResponsePayload::VmConfiguredResponse(_) => {} + wire::ResponsePayload::VmConfiguredResponse(configured) => configured + .projected_commands + .into_iter() + .map(|command| (command.name, command.guest_path)) + .collect(), wire::ResponsePayload::RejectedResponse(rejected) => { return Err(rejected_to_error(rejected)); } @@ -450,12 +462,14 @@ impl AgentOs { | wire::ResponsePayload::PersistenceFlushedResponse(_) | wire::ResponsePayload::VmFetchResponse(_) | wire::ResponsePayload::ExtEnvelope(_) + | wire::ResponsePayload::GuestKernelResultResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::PackageLinkedResponse(_) => { return Err(ClientError::Sidecar( "unexpected configure_vm response".to_string(), )); } - } + }; // 6b. Register host tool kits (if any): forward each tool definition via `register_host_callbacks`, // record the host execute callbacks in the per-VM registry, and install the shared @@ -527,6 +541,8 @@ impl AgentOs { | wire::ResponsePayload::PersistenceFlushedResponse(_) | wire::ResponsePayload::VmFetchResponse(_) | wire::ResponsePayload::ExtEnvelope(_) + | wire::ResponsePayload::GuestKernelResultResponse(_) + | wire::ResponsePayload::ResourceSnapshotResponse(_) | wire::ResponsePayload::PackageLinkedResponse(_) => { return Err(ClientError::Sidecar( "unexpected register_host_callbacks response".to_string(), @@ -563,7 +579,7 @@ impl AgentOs { session_id, vm_id, request_counter: AtomicI64::new(1), - linked_commands: parking_lot::Mutex::new(std::collections::HashSet::new()), + projected_commands: parking_lot::Mutex::new(projected_commands), process_registry_lock: parking_lot::Mutex::new(()), processes: SccHashMap::new(), process_counter: AtomicU64::new(1), @@ -632,21 +648,20 @@ impl AgentOs { .request_wire( wire_vm_ownership(&inner.connection_id, &inner.session_id, &inner.vm_id), wire::RequestPayload::LinkPackageRequest(wire::LinkPackageRequest { - // The wire `PackageDescriptor` carries `{ name, dir, acpEntrypoint? }`; - // forward all three from the client-side descriptor. + // The wire `PackageDescriptor` carries only `{ dir }`; the + // sidecar reads `name`/`acpEntrypoint` from the package's + // `agentos-package.json` at `dir`. package: wire::PackageDescriptor { - name: descriptor.name, dir: descriptor.dir, - acp_entrypoint: descriptor.acp_entrypoint, }, }), ) .await?; match response { wire::ResponsePayload::PackageLinkedResponse(linked) => { - let mut guard = inner.linked_commands.lock(); - for cmd in linked.commands { - guard.insert(cmd); + let mut guard = inner.projected_commands.lock(); + for command in linked.projected_commands { + guard.insert(command.name, command.guest_path); } Ok(()) } @@ -818,30 +833,6 @@ impl AgentOs { pub fn sidecar(&self) -> Arc { self.inner.sidecar.clone() } - - /// The commands each configured package *ships*, keyed by the package's - /// manifest name (matching [`SoftwareInfoDto::package`] on the actor-plugin - /// side). Read from each package dir the same way the sidecar's - /// `command_targets` does (`package.json` `bin`, else the `bin/` dir). An agent - /// package (no shipped commands) contributes an empty list. - /// - /// WORKAROUND: agent-os owns command *provisioning* (it forwards each package - /// dir), so it can read the host dirs here. The authoritative *resolved* set — - /// deduping when two packages provide the same command, priority order, and - /// executability — is owned by secure-exec's projection. This re-derives a - /// slice of that. TODO: replace with a secure-exec API that reports discovered - /// commands per package instead of us re-reading dirs. - pub fn provided_commands(&self) -> Vec<(String, Vec)> { - self.inner - .config - .packages - .iter() - .filter_map(|package| { - let manifest = read_agentos_package_manifest(&package.dir).ok()?; - Some((manifest.name, package_command_names(&package.dir))) - }) - .collect() - } } /// Abort and clear a single tracked background-task handle (e.g. the ACP event pump) so it cannot @@ -1032,14 +1023,16 @@ fn serialize_create_vm_config_for_sidecar( platform: vm_config::JsRuntimePlatform::default(), module_resolution: vm_config::JsModuleResolution::default(), allowed_builtins: Some(allowed.clone()), - // Agent SDK snapshotting is driven by the TypeScript client - // (`packages/core` resolves the per-agent `dist/sdk-snapshot.js` - // bundle). The Rust client does not resolve npm package bundles, so - // it forwards no snapshot. TODO: expose a snapshot bundle input on - // the Rust client config for parity if a Rust consumer needs it. - snapshot_userland_code: None, + high_resolution_time: None, } }), + bootstrap_commands: Some(vec![ + String::from("node"), + String::from("npm"), + String::from("npx"), + String::from("python"), + String::from("python3"), + ]), }) } @@ -3572,23 +3565,13 @@ fn json_object(entries: [(&str, Value); N]) -> Value { } /// The `agentos-package.json` manifest that lives at the root of every projected -/// package dir: the bare package name plus an optional agent block (its ACP -/// entrypoint command). The sidecar reads commands/version from the dir itself. +/// package dir. The client validates that the package manifest exists; the +/// sidecar owns agent resolution and reads commands/version from the dir itself. #[derive(serde::Deserialize)] -struct AgentosPackageManifest { - name: String, - #[serde(default)] - agent: Option, -} +struct AgentosPackageManifest {} -#[derive(serde::Deserialize)] -struct AgentosPackageAgent { - #[serde(rename = "acpEntrypoint")] - acp_entrypoint: Option, -} - -/// Read `/agentos-package.json` (name + optional agent block). An unreadable or -/// malformed manifest is an explicit error, not a silent skip. +/// Read `/agentos-package.json` (name only). An unreadable or malformed +/// manifest is an explicit error, not a silent skip. fn read_agentos_package_manifest(dir: &str) -> Result { let manifest_path = std::path::Path::new(dir).join("agentos-package.json"); let text = std::fs::read_to_string(&manifest_path).map_err(|error| { @@ -3613,52 +3596,16 @@ fn build_package_descriptors( ) -> Result, ClientError> { let mut descriptors = Vec::with_capacity(config.packages.len()); for package in &config.packages { - let manifest = read_agentos_package_manifest(&package.dir)?; + // Validate the package dir has a manifest, but the wire descriptor now + // carries only `dir`; the sidecar re-reads name/acpEntrypoint from it. + let _manifest = read_agentos_package_manifest(&package.dir)?; descriptors.push(wire::PackageDescriptor { - name: manifest.name, dir: package.dir.clone(), - acp_entrypoint: manifest.agent.and_then(|agent| agent.acp_entrypoint), }); } Ok(descriptors) } -/// The command names a projected package *ships*, mirroring the sidecar's -/// `command_targets`: the keys of `/package.json`'s `bin` map (or the unscoped -/// package name for a string `bin`), else the entries of `/bin/`. Sorted. -fn package_command_names(dir: &str) -> Vec { - let dir_path = std::path::Path::new(dir); - // Prefer the package.json `bin` field. - if let Ok(text) = std::fs::read_to_string(dir_path.join("package.json")) { - if let Ok(value) = serde_json::from_str::(&text) { - match value.get("bin") { - Some(serde_json::Value::String(_)) => { - if let Some(name) = value.get("name").and_then(|v| v.as_str()) { - let unscoped = name.rsplit('/').next().unwrap_or(name).to_owned(); - return vec![unscoped]; - } - } - Some(serde_json::Value::Object(map)) => { - let mut names: Vec = map.keys().cloned().collect(); - names.sort(); - return names; - } - _ => {} - } - } - } - // Fall back to the `bin/` directory listing. - let mut names: Vec = std::fs::read_dir(dir_path.join("bin")) - .into_iter() - .flatten() - .flatten() - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| !name.starts_with('_') && !name.starts_with('.')) - .collect(); - names.sort(); - names -} - fn serialize_mounts(config: &AgentOsConfig) -> Result, ClientError> { config .mounts diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 46cefb4a0..692080942 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -33,8 +33,6 @@ pub struct AgentOsConfig { pub loopback_exempt_ports: Vec, /// Allowed Node.js builtins. Default: the hardened native-bridge set. pub allowed_node_builtins: Option>, - /// Working directory used for guest module resolution. Default: host cwd. - pub module_access_cwd: Option, /// Root filesystem configuration. Default: overlay + bundled base snapshot. pub root_filesystem: RootFilesystemConfig, /// Additional mounts. @@ -91,11 +89,6 @@ impl AgentOsConfigBuilder { self } - pub fn module_access_cwd(mut self, cwd: impl Into) -> Self { - self.config.module_access_cwd = Some(cwd.into()); - self - } - pub fn root_filesystem(mut self, root: RootFilesystemConfig) -> Self { self.config.root_filesystem = root; self @@ -766,6 +759,27 @@ pub struct MountPlugin { pub config: Option, } +/// Mount a host `node_modules` directory into the VM at `/root/node_modules`. +/// +/// Rust mirror of TS `nodeModulesMount(...)` (`packages/core/src/host-dir-mount.ts`). +/// This is the explicit, mount-based replacement for the removed `moduleAccessCwd` +/// option: the guest module resolver reads the mounted tree through the kernel VFS, +/// so the caller supplies exactly the `node_modules` directory whose packages should +/// be resolvable in the guest. The mount is read-only. +pub fn node_modules_mount(host_node_modules_dir: impl Into) -> MountConfig { + MountConfig::Native { + path: "/root/node_modules".to_string(), + plugin: MountPlugin { + id: "host_dir".to_string(), + config: Some(serde_json::json!({ + "hostPath": host_node_modules_dir.into(), + "readOnly": true, + })), + }, + read_only: true, + } +} + /// Overlay mount filesystem config. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OverlayMountConfig { diff --git a/crates/client/src/fs.rs b/crates/client/src/fs.rs index 1421d9af8..ca5cfb46e 100644 --- a/crates/client/src/fs.rs +++ b/crates/client/src/fs.rs @@ -377,15 +377,6 @@ impl AgentOs { }) } - /// Posix `dirname` over an already-normalized absolute path (mirrors `posixPath.dirname`). - fn posix_dirname(path: &str) -> String { - match path.rfind('/') { - None => String::from("."), - Some(0) => String::from("/"), - Some(idx) => path[..idx].to_string(), - } - } - /// Join a parent directory with a child basename the way the TS fs code does (special-casing the /// root so it does not produce a leading `//`). fn join_child(dir: &str, child: &str) -> String { @@ -435,6 +426,7 @@ impl AgentOs { content: None, encoding: None, recursive: false, + max_depth: None, mode: None, uid: None, gid: None, @@ -527,9 +519,26 @@ impl AgentOs { let result = self .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadDir, path)) .await?; - // secure-exec's READ_DIR returns basenames only (`entries: list`); - // the typed [`Self::read_dir_with_types`] path derives each entry's type - // with a per-child `lstat`. + // secure-exec's READ_DIR now returns rich entries (`entries: + // list` with name + is_directory + is_symbolic_link); + // this name-only accessor projects the basenames. The richer fields back + // the typed [`Self::read_dir_with_types`] path. + Ok(result + .entries + .unwrap_or_default() + .into_iter() + .map(|entry| entry.name) + .collect()) + } + + async fn kernel_readdir_recursive( + &self, + path: &str, + max_depth: Option, + ) -> Result> { + let mut request = Self::fs_request(GuestFilesystemOperation::ReadDirRecursive, path); + request.max_depth = max_depth; + let result = self.guest_fs_call(request).await?; Ok(result.entries.unwrap_or_default()) } @@ -549,54 +558,21 @@ impl AgentOs { Ok(Self::virtual_stat_from(stat)) } - async fn kernel_readlink(&self, path: &str) -> Result { - let result = self - .guest_fs_call(Self::fs_request(GuestFilesystemOperation::ReadLink, path)) - .await?; - result.target.context("readlink response missing target") - } - - async fn kernel_symlink(&self, target: &str, path: &str) -> Result<()> { - let mut request = Self::fs_request(GuestFilesystemOperation::Symlink, path); - request.target = Some(target.to_string()); + async fn kernel_remove_path(&self, path: &str, recursive: bool) -> Result<()> { + let mut request = Self::fs_request(GuestFilesystemOperation::Remove, path); + request.recursive = recursive; self.guest_fs_call(request).await?; Ok(()) } - async fn kernel_rename(&self, from: &str, to: &str) -> Result<()> { - let mut request = Self::fs_request(GuestFilesystemOperation::Rename, from); + async fn kernel_move_path(&self, from: &str, to: &str) -> Result<()> { + let mut request = Self::fs_request(GuestFilesystemOperation::Move, from); request.destination_path = Some(to.to_string()); + request.recursive = true; self.guest_fs_call(request).await?; Ok(()) } - async fn kernel_chmod(&self, path: &str, mode: u32) -> Result<()> { - let mut request = Self::fs_request(GuestFilesystemOperation::Chmod, path); - request.mode = Some(mode); - self.guest_fs_call(request).await?; - Ok(()) - } - - async fn kernel_chown(&self, path: &str, uid: u32, gid: u32) -> Result<()> { - let mut request = Self::fs_request(GuestFilesystemOperation::Chown, path); - request.uid = Some(uid); - request.gid = Some(gid); - self.guest_fs_call(request).await?; - Ok(()) - } - - async fn kernel_remove_file(&self, path: &str) -> Result<()> { - self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveFile, path)) - .await?; - Ok(()) - } - - async fn kernel_remove_dir(&self, path: &str) -> Result<()> { - self.guest_fs_call(Self::fs_request(GuestFilesystemOperation::RemoveDir, path)) - .await?; - Ok(()) - } - /// Recursively create directories (`mkdir -p`). Uses the WRITABLE guard, then walks each path /// component and creates the ones that do not yet exist (mirrors TS `_mkdirp`). async fn mkdirp(&self, path: &str) -> Result<()> { @@ -611,75 +587,6 @@ impl AgentOs { } Ok(()) } - - /// Recursive copy preserving mode/uid/gid and replicating symlinks (mirrors TS `_copyPath`). - /// Boxed because it recurses across an `async fn` boundary. - fn copy_path<'a>( - &'a self, - from: &'a str, - to: &'a str, - ) -> futures::future::BoxFuture<'a, Result<()>> { - Box::pin(async move { - Self::assert_writable_absolute_path(to)?; - let stat = self.kernel_lstat(from).await?; - if stat.is_symbolic_link { - let target = self.kernel_readlink(from).await?; - self.kernel_symlink(&target, to).await?; - return Ok(()); - } - if stat.is_directory { - self.mkdirp(&Self::posix_dirname(to)).await?; - if !self.kernel_exists(to).await? { - self.kernel_mkdir(to).await?; - } - self.kernel_chmod(to, stat.mode).await?; - self.kernel_chown(to, stat.uid, stat.gid).await?; - let entries = self.kernel_readdir(from).await?; - for entry in entries { - if entry == "." || entry == ".." { - continue; - } - let from_path = Self::join_child(from, &entry); - let to_path = Self::join_child(to, &entry); - self.copy_path(&from_path, &to_path).await?; - } - return Ok(()); - } - let content = self.kernel_read_file(from).await?; - self.write_file(to, content).await?; - self.kernel_chmod(to, stat.mode).await?; - self.kernel_chown(to, stat.uid, stat.gid).await?; - Ok(()) - }) - } - - /// `delete`, boxed so the recursive child walk can cross the `async fn` boundary. - fn delete_inner<'a>( - &'a self, - path: &'a str, - recursive: bool, - ) -> futures::future::BoxFuture<'a, Result<()>> { - Box::pin(async move { - let stat = self.kernel_lstat(path).await?; - if stat.is_directory { - if recursive { - let entries = self.kernel_readdir(path).await?; - for entry in entries { - if entry == "." || entry == ".." { - continue; - } - let child = format!("{path}/{entry}"); - // Mirror TS `delete` recursion, which re-runs the safe-path guard on each - // child via the public `this.delete(...)` call before recursing. - Self::assert_safe_absolute_path(&child)?; - self.delete_inner(&child, true).await?; - } - } - return self.kernel_remove_dir(path).await; - } - self.kernel_remove_file(path).await - }) - } } // --------------------------------------------------------------------------- @@ -812,50 +719,39 @@ impl AgentOs { options: ReaddirRecursiveOptions, ) -> Result> { Self::assert_safe_absolute_path(path)?; - let max_depth = options.max_depth; let exclude: std::collections::HashSet<&str> = options.exclude.iter().map(String::as_str).collect(); + let entries = self + .kernel_readdir_recursive(path, options.max_depth) + .await?; + let mut excluded_prefixes: Vec = Vec::new(); let mut results: Vec = Vec::new(); - // BFS queue of `(dir_path, current_depth)`. - let mut queue: std::collections::VecDeque<(String, u32)> = - std::collections::VecDeque::new(); - queue.push_back((path.to_string(), 0)); - - while let Some((dir_path, depth)) = queue.pop_front() { - let entries = self.kernel_readdir(&dir_path).await?; - for name in entries { - if name == "." || name == ".." { - continue; - } - if exclude.contains(name.as_str()) { - continue; - } - let full_path = Self::join_child(&dir_path, &name); - let s = self.kernel_lstat(&full_path).await?; - if s.is_symbolic_link { - results.push(DirEntry { - path: full_path, - entry_type: DirEntryType::Symlink, - size: s.size, - }); - } else if s.is_directory { - results.push(DirEntry { - path: full_path.clone(), - entry_type: DirEntryType::Directory, - size: s.size, - }); - if max_depth.is_none() || depth < max_depth.unwrap() { - queue.push_back((full_path, depth + 1)); - } - } else { - results.push(DirEntry { - path: full_path, - entry_type: DirEntryType::File, - size: s.size, - }); + for entry in entries { + if excluded_prefixes.iter().any(|prefix| { + entry.path == *prefix || entry.path.starts_with(&format!("{prefix}/")) + }) { + continue; + } + if exclude.contains(entry.name.as_str()) { + if entry.is_directory && !entry.is_symbolic_link { + excluded_prefixes.push(entry.path); } + continue; } + + let entry_type = if entry.is_symbolic_link { + DirEntryType::Symlink + } else if entry.is_directory { + DirEntryType::Directory + } else { + DirEntryType::File + }; + results.push(DirEntry { + path: entry.path, + entry_type, + size: entry.size, + }); } Ok(results) @@ -936,24 +832,19 @@ impl AgentOs { Ok(()) } - /// Move a path. `lstat(from)` no-follow; symlink/non-dir -> rename; real dir -> recursive copy - /// (preserve mode/uid/gid/symlinks) + recursive delete. (TS `move`.) + /// Move a path through the sidecar primitive. The kernel attempts rename first, then falls back + /// to recursive copy+remove on EXDEV. pub async fn move_path(&self, from: &str, to: &str) -> Result<()> { Self::assert_writable_absolute_path(from)?; Self::assert_writable_absolute_path(to)?; - let source_stat = self.kernel_lstat(from).await?; - if !source_stat.is_directory || source_stat.is_symbolic_link { - return self.kernel_rename(from, to).await; - } - self.copy_path(from, to).await?; - self.delete(from, DeleteOptions { recursive: true }).await + self.kernel_move_path(from, to).await } - /// Delete a path. `lstat` to discriminate; recursive manually recurses children then `remove_dir`; - /// non-recursive dir -> `remove_dir` (ENOTEMPTY if non-empty). + /// Delete a path through the sidecar primitive. Non-recursive directory deletes preserve + /// ENOTEMPTY semantics. pub async fn delete(&self, path: &str, options: DeleteOptions) -> Result<()> { Self::assert_writable_absolute_path(path)?; - self.delete_inner(path, options.recursive).await + self.kernel_remove_path(path, options.recursive).await } /// Convert a wire [`RootFilesystemEntry`] into the public snapshot [`FilesystemEntry`], diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 273a00b5a..5760d87e8 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -73,7 +73,7 @@ pub use config::{ RootFilesystemKind, RootFilesystemMode, RootLowerInput, RulePermissions, ScheduleCallback, ScheduleDriver, ScheduleEntry, ScheduleHandle, SidecarJsBridgeCall, SidecarJsBridgeCallback, SoftwareInput, SoftwareKind, TimerScheduleDriver, ToolCallback, ToolKit, ToolLimits, - WasmLimits, + WasmLimits, node_modules_mount, }; pub use process::{ diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 02b9a89e1..08e62fbba 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -1,14 +1,15 @@ //! Agent sessions (ACP) methods + supporting types. //! -//! Ported from `packages/core/src/agent-os.ts` (session methods), `agent-session-types.ts` -//! (session/mode/config/capability/permission types), and `agents.ts` (`AgentType`, `AgentConfig`). +//! Ported from `packages/core/src/agent-os.ts` (session methods) and `agent-session-types.ts` +//! (session/mode/config/capability/permission types). Agent types are resolved dynamically +//! from the configured `/opt/agentos` package manifests (keyed by manifest `name`), exactly +//! as the TS client does — there is no hardcoded agent registry. //! //! ACP = JSON-RPC 2.0 over stdio. Sessions are referenced by string ID and return JSON-serializable //! data only. JSON-RPC errors are NOT Rust `Err`; methods that issue requests return a //! [`JsonRpcResponse`] whose `error` field may be set. use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::atomic::Ordering; @@ -18,15 +19,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use agentos_protocol::generated::v1::{ - AcpCloseSessionRequest, AcpCreateSessionRequest, AcpGetSessionStateRequest, AcpRequest, - AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind, AcpSessionCreatedResponse, - AcpSessionRequest, AcpSessionStateResponse, + AcpCloseSessionRequest, AcpCreateSessionRequest, AcpGetSessionStateRequest, + AcpListAgentsRequest, AcpRequest, AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind, + AcpSessionCreatedResponse, AcpSessionRequest, AcpSessionStateResponse, }; use agentos_protocol::ACP_EXTENSION_NAMESPACE; use secure_exec_client::wire; use crate::agent_os::{AgentOs, SessionEntry}; -use crate::config::{AgentOsConfig, MountConfig, ToolKit}; +use crate::config::ToolKit; use crate::error::ClientError; use crate::json_rpc::{JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse}; use crate::stream::Subscription; @@ -35,13 +36,6 @@ use crate::{CLOSED_SESSION_ID_RETENTION_LIMIT, PERMISSION_TIMEOUT_MS}; /// ACP method name for legacy permission requests/responses. const LEGACY_PERMISSION_METHOD: &str = "request/permission"; -/// Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter -/// bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint` -/// field; the sidecar reads the entrypoint from this key and strips it before -/// launching the adapter. Must stay in sync with the sidecar constant of the same -/// name in `crates/agentos-sidecar/src/acp_extension.rs`. -const RESUME_ADAPTER_ENTRYPOINT_ENV: &str = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; - /// ACP method name for permission requests issued by the agent to the host (TS /// `ACP_PERMISSION_METHOD`). Used by the host-request ACP dispatcher in `agent_os.rs`. pub(crate) const ACP_PERMISSION_METHOD: &str = "session/request_permission"; @@ -128,161 +122,17 @@ pub struct SessionInfo { pub agent_type: String, } -/// A registry agent entry from `list_agents`. +/// A registry agent entry from `list_agents`. Mirrors the TS `AgentRegistryEntry`. +/// The client is npm-agnostic and parses no manifests: `list_agents` is a sidecar +/// ACP RPC that enumerates the projected `/opt/agentos` packages. The entry is just +/// the agent `id`; `installed` is always `true` (the package is materialized into +/// the VM at boot). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentRegistryEntry { pub id: String, - #[serde(rename = "acpAdapter")] - pub acp_adapter: String, - #[serde(rename = "agentPackage")] - pub agent_package: String, pub installed: bool, } -/// Built-in agent ids (mirrors the keys of TS `AGENT_CONFIGS`). -const BUILTIN_AGENT_IDS: [&str; 4] = ["pi", "pi-cli", "opencode", "claude"]; - -/// A built-in agent configuration (port of a TS `AGENT_CONFIGS` entry). System-prompt assembly and -/// injection are owned by the sidecar. -struct AgentConfigDef { - acp_adapter: &'static str, - agent_package: &'static str, - default_env: &'static [(&'static str, &'static str)], -} - -/// Resolve a built-in agent type to its config (port of TS `AGENT_CONFIGS`). -fn agent_config(agent_type: &str) -> Option { - Some(match agent_type { - "pi" => AgentConfigDef { - acp_adapter: "@agentos-software/pi", - agent_package: "@mariozechner/pi-coding-agent", - default_env: &[], - }, - "pi-cli" => AgentConfigDef { - acp_adapter: "pi-acp", - agent_package: "@mariozechner/pi-coding-agent", - default_env: &[], - }, - "opencode" => AgentConfigDef { - acp_adapter: "@agentos-software/opencode", - agent_package: "@agentos-software/opencode", - default_env: &[ - ("OPENCODE_DISABLE_CONFIG_DEP_INSTALL", "1"), - ("OPENCODE_DISABLE_EMBEDDED_WEB_UI", "1"), - ], - }, - "claude" => AgentConfigDef { - acp_adapter: "@agentos-software/claude-code", - agent_package: "@anthropic-ai/claude-agent-sdk", - default_env: &[ - ("CLAUDE_AGENT_SDK_CLIENT_APP", "@rivet-dev/agentos"), - ("CLAUDE_CODE_SIMPLE", "1"), - ("CLAUDE_CODE_FORCE_AGENT_OS_RIPGREP", "1"), - ("CLAUDE_CODE_DEFER_GROWTHBOOK_INIT", "1"), - ("CLAUDE_CODE_DISABLE_CWD_PERSIST", "1"), - ("CLAUDE_CODE_DISABLE_DEV_NULL_REDIRECT", "1"), - ("CLAUDE_CODE_NODE_SHELL_WRAPPER", "1"), - ("CLAUDE_CODE_DISABLE_STREAM_JSON_HOOK_EVENTS", "1"), - ("CLAUDE_CODE_SHELL", "/bin/sh"), - ("CLAUDE_CODE_SKIP_INITIAL_MESSAGES", "1"), - ("CLAUDE_CODE_SKIP_SANDBOX_INIT", "1"), - ("CLAUDE_CODE_SIMPLE_SHELL_EXEC", "1"), - ("CLAUDE_CODE_SWAP_STDIO", "0"), - ("CLAUDE_CODE_USE_PIPE_OUTPUT", "1"), - ("DISABLE_TELEMETRY", "1"), - ("SHELL", "/bin/sh"), - ("USE_BUILTIN_RIPGREP", "0"), - ], - }, - _ => return None, - }) -} - -/// Resolve a package's VM bin entrypoint from the host `node_modules` (port of -/// TS `_resolvePackageBin`). Prefer legacy `module_access_cwd/node_modules`, -/// then fall back to the host directory backing a native `/root/node_modules` -/// mount. The latter is the RivetKit actor path: the TS shim no longer forwards -/// `moduleAccessCwd`; callers explicitly mount the desired `node_modules` -/// directory instead. -fn resolve_package_bin( - config: &AgentOsConfig, - package_name: &str, - bin_name: Option<&str>, -) -> std::result::Result { - let mut candidates = Vec::new(); - let module_access_cwd = config - .module_access_cwd - .clone() - .unwrap_or_else(|| ".".to_string()); - candidates.push( - Path::new(&module_access_cwd) - .join("node_modules") - .join(package_name) - .join("package.json"), - ); - candidates.extend(node_modules_mount_package_json_paths(config, package_name)); - - let contents = candidates - .iter() - .find_map(|path| std::fs::read_to_string(path).ok()) - .ok_or_else(|| { - let looked = candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", "); - ClientError::Sidecar(format!( - "cannot resolve package {package_name}: no package.json found (looked in {looked})" - )) - })?; - let pkg: Value = serde_json::from_str(&contents).map_err(|error| { - ClientError::Sidecar(format!("invalid package.json for {package_name}: {error}")) - })?; - let bin_entry: Option = match &pkg["bin"] { - Value::String(bin) => Some(bin.clone()), - Value::Object(map) => bin_name - .and_then(|name| map.get(name)) - .or_else(|| map.get(package_name)) - .or_else(|| map.values().next()) - .and_then(|value| value.as_str()) - .map(|bin| bin.to_string()), - _ => None, - }; - let bin_entry = bin_entry.ok_or_else(|| { - ClientError::Sidecar(format!("No bin entry found in {package_name}/package.json")) - })?; - Ok(format!("/root/node_modules/{package_name}/{bin_entry}")) -} - -fn node_modules_mount_package_json_paths( - config: &AgentOsConfig, - package_name: &str, -) -> Vec { - config - .mounts - .iter() - .filter_map(|mount| { - let MountConfig::Native { - path, - plugin, - read_only: _, - } = mount - else { - return None; - }; - if path != "/root/node_modules" || plugin.id != "host_dir" { - return None; - } - let host_path = plugin - .config - .as_ref() - .and_then(|config| config.get("hostPath")) - .and_then(Value::as_str)?; - Some(Path::new(host_path).join(package_name).join("package.json")) - }) - .collect() -} - /// MCP server config used by `create_session`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] @@ -1282,28 +1132,27 @@ impl AgentOs { sessions } - /// List available agents (host FS). Unions package agent ids + the built-in `AGENT_CONFIGS` - /// keys; `installed` is determined by reading the adapter `package.json` (host FS, try/catch). - /// - /// PARITY GAP: the agent-config registry (`AGENT_CONFIGS`, package agent configs, software - /// roots, adapter `package.json` resolution) does not exist in the client scaffold and lives in - /// shared modules this task may not edit. Returns an empty list until that infrastructure is - /// added. See `todosLeft`. - pub fn list_agents(&self) -> Vec { - BUILTIN_AGENT_IDS - .iter() - .filter_map(|id| { - let config = agent_config(id)?; - let installed = - resolve_package_bin(self.config(), config.acp_adapter, None).is_ok(); - Some(AgentRegistryEntry { - id: (*id).to_string(), - acp_adapter: config.acp_adapter.to_string(), - agent_package: config.agent_package.to_string(), - installed, - }) + /// List available agents. A thin forwarder: sends `AcpListAgentsRequest` and + /// maps the sidecar's response. The sidecar enumerates the projected + /// `/opt/agentos` packages (client parses no manifests). Every such agent is a + /// package materialized into the VM at boot, so `installed` is always `true`. + pub async fn list_agents(&self) -> Result> { + let response = self + .send_acp_request(AcpRequest::AcpListAgentsRequest(AcpListAgentsRequest { + reserved: false, + })) + .await?; + let AcpResponse::AcpListAgentsResponse(listed) = response else { + return Err(unexpected_acp_response("AcpListAgentsRequest", response).into()); + }; + Ok(listed + .agents + .into_iter() + .map(|agent| AgentRegistryEntry { + id: agent.id, + installed: agent.installed, }) - .collect() + .collect()) } /// Create an ACP session. Resolves the agent config, merges env (user wins), creates the session @@ -1316,30 +1165,10 @@ impl AgentOs { agent_type: &str, options: CreateSessionOptions, ) -> Result { - let config = agent_config(agent_type) - .ok_or_else(|| ClientError::Sidecar(format!("Unknown agent type: {agent_type}")))?; - - // Resolve the ACP adapter's VM bin entrypoint from the host node_modules (mirrors TS - // `_resolveAdapterBin` / `_resolvePackageBin`). - let adapter_entrypoint = resolve_package_bin(self.config(), config.acp_adapter, None)?; - - // Merge env: agent default_env (lowest) -> user env (wins). - let mut env: BTreeMap = config - .default_env - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect(); - for (key, value) in &options.env { - env.insert(key.clone(), value.clone()); - } - if (agent_type == "pi" || agent_type == "pi-cli") && !env.contains_key("PI_ACP_PI_COMMAND") - { - if let Ok(pi_command) = - resolve_package_bin(self.config(), config.agent_package, Some("pi")) - { - env.insert("PI_ACP_PI_COMMAND".to_string(), pi_command); - } - } + // The client is npm-agnostic: it sends only the agent name. The sidecar + // resolves the name -> package -> entrypoint/env/launchArgs from the + // projected `/opt/agentos//current/agentos-package.json` and spawns. + let env: BTreeMap = options.env.clone(); let cwd = options .cwd @@ -1363,7 +1192,6 @@ impl AgentOs { AcpCreateSessionRequest { agent_type: agent_type.to_string(), runtime: AcpRuntimeKind::JavaScript, - adapter_entrypoint, args: Vec::new(), env: env.into_iter().collect(), cwd, @@ -1466,33 +1294,10 @@ impl AgentOs { agent_type: &str, options: ResumeSessionOptions, ) -> Result { - let config = agent_config(agent_type) - .ok_or_else(|| ClientError::Sidecar(format!("Unknown agent type: {agent_type}")))?; - let adapter_entrypoint = resolve_package_bin(self.config(), config.acp_adapter, None)?; - - // Merge env: agent default_env (lowest) -> user env (wins), then carry the - // resolved adapter entrypoint under the sidecar's reserved key (the resume - // wire request has no dedicated `adapterEntrypoint` field). - let mut env: BTreeMap = config - .default_env - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect(); - for (key, value) in &options.env { - env.insert(key.clone(), value.clone()); - } - if (agent_type == "pi" || agent_type == "pi-cli") && !env.contains_key("PI_ACP_PI_COMMAND") - { - if let Ok(pi_command) = - resolve_package_bin(self.config(), config.agent_package, Some("pi")) - { - env.insert("PI_ACP_PI_COMMAND".to_string(), pi_command); - } - } - env.insert( - RESUME_ADAPTER_ENTRYPOINT_ENV.to_string(), - adapter_entrypoint, - ); + // The client is npm-agnostic: it sends only the agent name. The sidecar + // resolves the name -> package -> entrypoint/env/launchArgs from the + // projected manifest, exactly as `create_session` does. + let env: BTreeMap = options.env.clone(); let cwd = options .cwd diff --git a/crates/client/tests/common/mod.rs b/crates/client/tests/common/mod.rs index b332ba976..ea13d59a6 100644 --- a/crates/client/tests/common/mod.rs +++ b/crates/client/tests/common/mod.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::Once; use agentos_client::config::{ - AgentOsConfig, AgentOsSidecarConfig, MountConfig, MountPlugin, Permissions, + node_modules_mount, AgentOsConfig, AgentOsSidecarConfig, MountConfig, MountPlugin, Permissions, }; use agentos_client::AgentOs; @@ -68,12 +68,12 @@ pub async fn new_vm() -> AgentOs { pub async fn new_vm_with_sidecar_pool(pool: impl Into) -> AgentOs { ensure_sidecar_env(); AgentOs::create(AgentOsConfig { - module_access_cwd: Some( + mounts: vec![node_modules_mount( PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") + .join("../../node_modules") .to_string_lossy() .into_owned(), - ), + )], sidecar: Some(AgentOsSidecarConfig::Shared { pool: Some(pool.into()), }), @@ -107,15 +107,16 @@ async fn new_vm_with_config( permissions: Option, ) -> AgentOs { ensure_sidecar_env(); + let mut all_mounts = vec![node_modules_mount( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../node_modules") + .to_string_lossy() + .into_owned(), + )]; + all_mounts.extend(mounts); AgentOs::create(AgentOsConfig { loopback_exempt_ports, - module_access_cwd: Some( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .to_string_lossy() - .into_owned(), - ), - mounts, + mounts: all_mounts, permissions, ..Default::default() }) diff --git a/crates/client/tests/os_instructions_e2e.rs b/crates/client/tests/os_instructions_e2e.rs index 3f96faf61..a07845f8c 100644 --- a/crates/client/tests/os_instructions_e2e.rs +++ b/crates/client/tests/os_instructions_e2e.rs @@ -14,8 +14,8 @@ use std::path::Path; use std::sync::Arc; use agentos_client::config::{ - AgentOsConfig, AgentOsSidecarConfig, FsPermissions, HostTool, PatternPermissions, - PermissionMode, Permissions, ToolKit, + node_modules_mount, AgentOsConfig, AgentOsSidecarConfig, FsPermissions, HostTool, + PatternPermissions, PermissionMode, Permissions, ToolKit, }; use agentos_client::{AgentOs, CreateSessionOptions}; use serde_json::json; @@ -79,8 +79,8 @@ fn allow_all_permissions() -> Permissions { /// Lay out a fake `node_modules/@agentos-software/pi` whose `bin` resolves to the mock adapter, /// so the client's `resolve_package_bin("pi")` path projects it into the guest at /// `/root/node_modules/@agentos-software/pi/adapter.mjs` and the sidecar launches it. -fn write_mock_pi_adapter(module_access_cwd: &std::path::Path) { - let package_dir = module_access_cwd +fn write_mock_pi_adapter(module_root: &std::path::Path) { + let package_dir = module_root .join("node_modules") .join("@agentos-software") .join("pi"); @@ -122,7 +122,12 @@ async fn run_session( tool_kits: Vec, ) -> Vec { let os = AgentOs::create(AgentOsConfig { - module_access_cwd: Some(module_access_dir.to_string_lossy().into_owned()), + mounts: vec![node_modules_mount( + module_access_dir + .join("node_modules") + .to_string_lossy() + .into_owned(), + )], sidecar: Some(AgentOsSidecarConfig::Shared { pool: Some(format!("os-instructions-{}", Uuid::new_v4())), }), diff --git a/crates/client/tests/pi_session_e2e.rs b/crates/client/tests/pi_session_e2e.rs index 15435d42c..0fc89b84e 100644 --- a/crates/client/tests/pi_session_e2e.rs +++ b/crates/client/tests/pi_session_e2e.rs @@ -23,7 +23,9 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; -use agentos_client::config::{AgentOsConfig, PatternPermissions, PermissionMode, Permissions}; +use agentos_client::config::{ + node_modules_mount, AgentOsConfig, PatternPermissions, PermissionMode, Permissions, +}; use agentos_client::fs::MkdirOptions; use agentos_client::{AgentOs, CreateSessionOptions}; @@ -127,7 +129,12 @@ async fn pi_session_create_prompt_close() { common::ensure_sidecar_env(); let os = AgentOs::create(AgentOsConfig { - module_access_cwd: Some(module_cwd), + mounts: vec![node_modules_mount( + Path::new(&module_cwd) + .join("node_modules") + .to_string_lossy() + .into_owned(), + )], loopback_exempt_ports: vec![port], permissions: Some(Permissions { network: Some(PatternPermissions::Mode(PermissionMode::Allow)), diff --git a/crates/client/tests/session_e2e.rs b/crates/client/tests/session_e2e.rs index 12ae6955b..9c8e033c7 100644 --- a/crates/client/tests/session_e2e.rs +++ b/crates/client/tests/session_e2e.rs @@ -103,31 +103,17 @@ async fn session_surface_create_prompt_events_close() { let os = common::new_vm_with_loopback_ports(vec![mock.port]).await; // --- Runtime-independent session surface (no agents/V8 needed) -------------------------------- - // Real assertions against the real sidecar: the registry starts empty, the built-in agent set is - // listed, and every session operation on an unknown id reports SessionNotFound. + // Real assertions against the real sidecar: the registry starts empty, agents are resolved + // dynamically from the configured `/opt/agentos` package manifests (there is NO hardcoded + // agent registry), and every session operation on an unknown id reports SessionNotFound. assert!(os.list_sessions().is_empty(), "a fresh VM has no sessions"); - let agents = os.list_agents(); - let expected_agent_ids = ["pi", "pi-cli", "opencode", "claude"]; - assert_eq!( - agents.len(), - expected_agent_ids.len(), - "only the active built-in agents must be listed" - ); - for expected_agent_id in expected_agent_ids { - assert!( - agents.iter().any(|agent| agent.id == expected_agent_id), - "list_agents must include the {expected_agent_id} agent config" - ); - } + // This VM configures no `/opt/agentos` agent packages, so no agents are listed. `list_agents` + // is a sidecar ACP RPC that enumerates the projected `/opt/agentos` packages; with none + // projected it returns empty. + let agents = os.list_agents().await.expect("list_agents"); assert!( - agents.iter().all(|agent| agent.id != "codex"), - "list_agents must not include a codex built-in agent config" - ); - assert!( - agents - .iter() - .any(|a| a.id == "pi" && a.acp_adapter == "@agentos-software/pi"), - "list_agents must include the pi agent config" + agents.is_empty(), + "with no agent packages configured, list_agents must be empty (dynamic resolution)" ); assert!( matches!( diff --git a/examples/quickstart/pi-extensions/index.ts b/examples/quickstart/pi-extensions/index.ts index 45f78abf2..47a05dc01 100644 --- a/examples/quickstart/pi-extensions/index.ts +++ b/examples/quickstart/pi-extensions/index.ts @@ -13,8 +13,8 @@ // inside the VM before creating the session. import { createRequire } from "node:module"; -import { dirname, resolve } from "node:path"; -import { AgentOs } from "@rivet-dev/agentos-core"; +import { dirname, join, resolve } from "node:path"; +import { AgentOs, nodeModulesMount } from "@rivet-dev/agentos-core"; import pi from "@agentos-software/pi"; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -59,7 +59,7 @@ const vm = await AgentOs.create({ loopbackExemptPorts: [Number(new URL(ANTHROPIC_BASE_URL).port)], } : {}), - moduleAccessCwd: MODULE_ACCESS_CWD, + mounts: [nodeModulesMount(join(MODULE_ACCESS_CWD, "node_modules"))], software: [pi], }); diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index e54b908c5..3ba9f0c23 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -10,14 +10,10 @@ * config envelope across the bridge — it owns no agent-os runtime logic. */ -import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import common from "@agentos-software/common"; -import { - OPT_AGENTOS_BIN, - OPT_AGENTOS_ROOT, -} from "@rivet-dev/agentos-core"; +import { OPT_AGENTOS_ROOT } from "@rivet-dev/agentos-core"; import { getSidecarPath } from "@rivet-dev/agentos-sidecar"; import { actor, @@ -58,28 +54,8 @@ interface NativeMountLike { readOnly?: boolean; } -interface PackageAgentManifest { - acpEntrypoint: string; - env?: Record; - launchArgs?: string[]; - snapshot?: boolean; -} - -interface PackageManifest { - name: string; - agent?: PackageAgentManifest; -} - interface NormalizedPackageRef { dir: string; - legacyManifest?: PackageManifest; -} - -interface SerializedAgentConfig { - name: string; - adapterEntrypoint: string; - launchArgs?: string[]; - defaultEnv?: Record; } /** @@ -131,131 +107,14 @@ function normalizePackageRef(value: unknown): NormalizedPackageRef | undefined { } const record = toRecord(value); if (typeof record.packageDir === "string") { - return { - dir: record.packageDir, - legacyManifest: legacyPackageManifest(record), - }; + return { dir: record.packageDir }; } if (typeof record.dir === "string") { - return { - dir: record.dir, - legacyManifest: legacyPackageManifest(record), - }; + return { dir: record.dir }; } return undefined; } -function legacyPackageManifest( - record: Record, -): PackageManifest | undefined { - if (typeof record.name !== "string") { - return undefined; - } - const manifest: PackageManifest = { name: record.name }; - const agent = toRecord(record.agent); - if (typeof agent.acpEntrypoint === "string") { - manifest.agent = { - acpEntrypoint: agent.acpEntrypoint, - ...(isStringRecord(agent.env) ? { env: agent.env } : {}), - ...(Array.isArray(agent.launchArgs) && - agent.launchArgs.every((arg) => typeof arg === "string") - ? { launchArgs: agent.launchArgs } - : {}), - ...(typeof agent.snapshot === "boolean" ? { snapshot: agent.snapshot } : {}), - }; - } - return manifest; -} - -function readPackageManifestForClient( - ref: NormalizedPackageRef, -): PackageManifest | undefined { - return tryReadAgentosPackageManifest(ref.dir) ?? ref.legacyManifest; -} - -function tryReadAgentosPackageManifest( - dir: string, -): PackageManifest | undefined { - try { - return readAgentosPackageManifest(dir); - } catch (error) { - if (error instanceof Error && errorCode(error) === "ENOENT") { - return undefined; - } - throw error; - } -} - -function readAgentosPackageManifest(dir: string): PackageManifest { - const manifestPath = join(dir, "agentos-package.json"); - let parsed: unknown; - try { - parsed = JSON.parse(readFileSync(manifestPath, "utf8")); - } catch (error) { - const wrapped = new Error( - `Failed to read agentOS package manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - const code = errorCode(error); - if (code !== undefined) { - Object.assign(wrapped, { code }); - } - throw wrapped; - } - return validateAgentosPackageManifest(parsed, manifestPath); -} - -function errorCode(error: unknown): string | undefined { - if (!isPlainObject(error)) { - return undefined; - } - return typeof error.code === "string" ? error.code : undefined; -} - -function validateAgentosPackageManifest( - value: unknown, - source: string, -): PackageManifest { - if (!isPlainObject(value) || typeof value.name !== "string") { - throw new Error(`Invalid agentOS package manifest at ${source}: missing name`); - } - const manifest: PackageManifest = { name: value.name }; - if (value.agent !== undefined) { - if ( - !isPlainObject(value.agent) || - typeof value.agent.acpEntrypoint !== "string" - ) { - throw new Error( - `Invalid agentOS package manifest at ${source}: invalid agent.acpEntrypoint`, - ); - } - manifest.agent = { - acpEntrypoint: value.agent.acpEntrypoint, - ...(isStringRecord(value.agent.env) ? { env: value.agent.env } : {}), - ...(Array.isArray(value.agent.launchArgs) && - value.agent.launchArgs.every((arg) => typeof arg === "string") - ? { launchArgs: value.agent.launchArgs } - : {}), - ...(typeof value.agent.snapshot === "boolean" - ? { snapshot: value.agent.snapshot } - : {}), - }; - } - return manifest; -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isStringRecord(value: unknown): value is Record { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) && - Object.values(value).every((entry) => typeof entry === "string") - ); -} - function normalizedPackageRefs(software: unknown[]): NormalizedPackageRef[] { const refs: NormalizedPackageRef[] = []; const seen = new Set(); @@ -268,23 +127,6 @@ function normalizedPackageRefs(software: unknown[]): NormalizedPackageRef[] { return refs; } -function serializedAgentConfigs( - packageRefs: NormalizedPackageRef[], -): SerializedAgentConfig[] { - const configs: SerializedAgentConfig[] = []; - for (const ref of packageRefs) { - const manifest = readPackageManifestForClient(ref); - if (!manifest?.agent) continue; - configs.push({ - name: manifest.name, - adapterEntrypoint: `${OPT_AGENTOS_BIN}/${manifest.agent.acpEntrypoint}`, - launchArgs: manifest.agent.launchArgs, - defaultEnv: manifest.agent.env, - }); - } - return configs; -} - export function buildConfigJson( parsed: AgentOsActorConfig, ): string { @@ -297,15 +139,15 @@ export function buildConfigJson( defaultSoftwareEnabled ? [common, ...softwareInput] : softwareInput, ); const packages = packageRefs.map((ref) => ({ dir: ref.dir })); - const agentConfigs = serializedAgentConfigs(packageRefs); const mounts = serializeNativeMounts(options.mounts); const sidecar = serializeSidecar(options.sidecar); return JSON.stringify({ + // The actor forwards ONLY package dirs; the sidecar resolves each agent from + // the projected `/opt/agentos//current/agentos-package.json` (no + // client-side adapter-entrypoint resolution — see root CLAUDE.md). packages, packagesMountAt: OPT_AGENTOS_ROOT, - agentConfigs, additionalInstructions: options.additionalInstructions, - moduleAccessCwd: options.moduleAccessCwd, loopbackExemptPorts: options.loopbackExemptPorts, allowedNodeBuiltins: options.allowedNodeBuiltins, permissions: options.permissions, diff --git a/packages/agentos/src/config.ts b/packages/agentos/src/config.ts index 510f26e7f..06fb85a87 100644 --- a/packages/agentos/src/config.ts +++ b/packages/agentos/src/config.ts @@ -28,7 +28,6 @@ export const nativeAgentOsOptionsSchema = z allowedNodeBuiltins: agentOsOptionFieldSchemas.allowedNodeBuiltins, rootFilesystem: agentOsOptionFieldSchemas.rootFilesystem, mounts: z.array(nativeMountConfigSchema).optional(), - moduleAccessCwd: agentOsOptionFieldSchemas.moduleAccessCwd, additionalInstructions: agentOsOptionFieldSchemas.additionalInstructions, permissions: agentOsOptionFieldSchemas.permissions, sidecar: sharedSidecarConfigSchema.optional(), @@ -80,7 +79,6 @@ export type NativeAgentOsOptions = Pick< | "loopbackExemptPorts" | "allowedNodeBuiltins" | "rootFilesystem" - | "moduleAccessCwd" | "additionalInstructions" | "permissions" | "limits" diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index e005a41a6..7ad2c6c33 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -43,7 +43,7 @@ - Currently configured agents: PI (`@agentos-software/pi`), PI CLI (`@agentos-software/pi-cli`), OpenCode (`@agentos-software/opencode`), Claude (`@agentos-software/claude-code`), and Codex (`@agentos-software/codex` + `@agentos-software/codex-cli`). - **No host agent exceptions.** Host-native wrappers and host binary launch paths are not allowed. OpenCode support must use the real upstream OpenCode implementation rebuilt into the VM adapter package and executed inside the VM. - `createSession("pi")` spawns the ACP adapter inside the VM, which calls the Pi SDK directly -- Keep `src/agents.ts` aligned with the shipped registry agent packages. Derive the built-in `AgentType` union from `AGENT_CONFIGS` instead of maintaining a separate manual list, and verify launch args/env with the mock-adapter session tests when adding or changing an agent. +- Agents are resolved BY THE SIDECAR, not the client. There is no `AGENT_CONFIGS` table; `AgentType` is just `string` (a manifest `name`, defined in `types.ts`). The client is npm-agnostic and parses NO manifests: `createSession(name)`/`resumeSession(name)` send only `agentType` on the wire (there is no `adapterEntrypoint` field), and the SIDECAR resolves the entrypoint/env/launchArgs from the projected `/opt/agentos//current/agentos-package.json`. `listAgents()` is a sidecar ACP RPC (`AcpListAgentsRequest`) — the sidecar enumerates the projected `/opt/agentos` packages. Adding/changing an agent = changing its package manifest (in the secure-exec registry), not this file; verify launch args/env with the mock-adapter session tests. The Rust client (`crates/client`) behaves IDENTICALLY (also sends only `agentType`) — see the root `CLAUDE.md` client-parity/npm-agnostic rule. - In `createSession()`, treat `skipOsInstructions` as "skip the base `/etc/agentos/instructions.md` text" only. Still call agent `prepareInstructions(...)` when session-level `additionalInstructions` or tool-reference content exists, and forward `skipBase` through the options object instead of blanking out the caller's extra instructions. - ACP agents that issue live `session/request_permission` calls during `session/prompt` cannot rely on queued session events alone. Route those permission round-trips through the sidecar callback channel (`SidecarRequestPayload`) so the host can answer them before the prompt request completes. - Native-sidecar inbound ACP host callbacks are explicit sidecar-request payloads now. If Rust forwards an unknown ACP JSON-RPC request, answer it through `SidecarRequestPayload.type === "acp_request"` with an `acp_request_result` JSON-RPC response; otherwise the sidecar will only synthesize `-32601` after the callback transport is unavailable or times out. @@ -59,11 +59,7 @@ Each agent type can have two adapter approaches: ### Agent Configs -Each agent type needs: -- `acpAdapter`: npm package name for the ACP adapter (e.g., `@agentos-software/pi`) -- `agentPackage`: npm package name for the underlying agent (e.g., `@mariozechner/pi-coding-agent`) -- Any environment variables or flags needed -- Package-provided agent descriptors registered through `processSoftware()` override the hardcoded `AGENT_CONFIGS` entries at session launch time. If a default shell/env tweak matters for both built-in and packaged flows, keep the two config surfaces in sync. +An agent's launch config lives entirely in its `/opt/agentos` package `agentos-package.json` manifest (`agent.acpEntrypoint`/`launchArgs`/`env`). Resolution is SIDECAR-SIDE: the client sends only the agent `name` (the `createSession(name)` id), and the sidecar reads the projected `/opt/agentos//current/agentos-package.json` to resolve the entrypoint (`/opt/agentos/bin/`), the manifest `env` (applied as defaults; caller env wins), and `launchArgs` (prepended before caller args), then spawns. The client parses no manifests and holds no per-agent config. There is no second hardcoded config surface to keep in sync — a default shell/env tweak is a manifest change. ## Testing diff --git a/packages/core/README.md b/packages/core/README.md index 2326b8fae..b5482c23f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -112,7 +112,7 @@ await vm.dispose(); | Method | Signature | Description | |--------|-----------|-------------| -| `createSession` | `createSession(agentType: AgentType \| string, options?: CreateSessionOptions): Promise<{ sessionId: string }>` | Launch an agent and return a session ID | +| `createSession` | `createSession(agentType: AgentType, options?: CreateSessionOptions): Promise<{ sessionId: string }>` | Launch an agent and return a session ID | | `listSessions` | `listSessions(): SessionInfo[]` | List active sessions | | `destroySession` | `destroySession(sessionId: string): Promise` | Gracefully cancel and close a session | @@ -177,8 +177,8 @@ await vm.dispose(); - `BatchReadResult` — Result of a batch read (path, content, error?) **Agent** -- `AgentType` — `"pi" | "pi-cli" | "opencode" | "claude"` -- `AgentConfig` — Agent configuration (acpAdapter, agentPackage, prepareInstructions) +- `AgentType` — `string` (a package manifest `name`, e.g. `"pi"`, `"claude"`); agents are resolved dynamically from the configured `/opt/agentos` package manifests, so any manifest `name` is valid +- `AgentConfig` — Agent configuration (adapterEntrypoint, launchArgs, defaultEnv) - `AgentRegistryEntry` — Registry entry (id, acpAdapter, agentPackage, installed) **Session** diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index efca7888d..cfac73147 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -3,21 +3,15 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, - mkdtempSync, readdirSync, - readFileSync, - rmSync, statSync, - writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; import { join, posix as posixPath, resolve as resolveHostPath, } from "node:path"; import { fileURLToPath } from "node:url"; -import type { AgentosPackageManifest } from "@agentos-software/manifest"; import type { MountConfigJsonObject, MountConfigJsonValue, @@ -92,14 +86,6 @@ export type { ConnectTerminalOptions } from "./runtime-compat.js"; const ACP_PROTOCOL_VERSION = 1; const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp"; const SHELL_DISPOSE_TIMEOUT_MS = 5_000; -/** - * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter - * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint` - * field; the sidecar reads the entrypoint from this key and strips it before - * launching the adapter. Must stay in sync with the sidecar constant of the same - * name in `crates/agentos-sidecar/src/acp_extension.rs`. - */ -const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT"; function defaultAcpClientCapabilities(): Record { return { @@ -170,16 +156,10 @@ export interface BatchReadResult { /** Entry in the agent registry, describing an available agent type. */ export interface AgentRegistryEntry { id: string; - /** npm adapter package (legacy agents) — absent for `/opt/agentos` packages. */ - acpAdapter?: string; - /** npm agent package (legacy agents) — absent for `/opt/agentos` packages. */ - agentPackage?: string; - /** Pre-resolved adapter command path for an `/opt/agentos` agent package. */ - adapterEntrypoint?: string; installed: boolean; } -import type { AgentConfig, AgentType } from "./agents.js"; +import type { AgentType } from "./types.js"; import { getBaseEnvironment } from "./base-filesystem.js"; import { CronManager } from "./cron/cron-manager.js"; import type { ScheduleDriver } from "./cron/schedule-driver.js"; @@ -209,21 +189,16 @@ import { type SnapshotLayerHandle, } from "./layers.js"; import { - resolveAgentSnapshotBundle, type SoftwareInput, type SoftwareRoot, } from "./packages.js"; import { - OPT_AGENTOS_BIN, OPT_AGENTOS_ROOT, type PackageRef, type SoftwarePackageRef, tryReadAgentosPackageManifest, } from "./agentos-package.js"; -import { - resolveDefaultSoftware, - resolveDependencyAgents, -} from "./default-software.js"; +import { resolveDefaultSoftware } from "./default-software.js"; import type { PermissionTier } from "./runtime.js"; import { allowAll, createNodeHostNetworkAdapter } from "./runtime-compat.js"; import { @@ -616,11 +591,6 @@ export interface AgentOsOptions { rootFilesystem?: RootFilesystemConfig; /** Filesystems to mount at boot time. */ mounts?: MountConfig[]; - /** - * @deprecated Use `mounts: [nodeModulesMount(path)]` instead. - * Compatibility alias for mounting `/node_modules` at `/root/node_modules`. - */ - moduleAccessCwd?: string; /** Additional instructions appended to the base OS system prompt injected at session start. */ additionalInstructions?: string; /** Custom schedule driver for cron jobs. Defaults to TimerScheduleDriver. */ @@ -834,7 +804,6 @@ function toRecord(value: unknown): Record { interface NormalizedPackageRef { dir: string; - legacyManifest?: AgentosPackageManifest; } function normalizePackageRef(value: unknown): NormalizedPackageRef | undefined { @@ -843,57 +812,14 @@ function normalizePackageRef(value: unknown): NormalizedPackageRef | undefined { } const record = toRecord(value); if (typeof record.packageDir === "string") { - return { - dir: record.packageDir, - legacyManifest: legacyPackageManifest(record), - }; + return { dir: record.packageDir }; } if (typeof record.dir === "string") { - return { - dir: record.dir, - legacyManifest: legacyPackageManifest(record), - }; + return { dir: record.dir }; } return undefined; } -function legacyPackageManifest( - record: Record, -): AgentosPackageManifest | undefined { - if (typeof record.name !== "string") { - return undefined; - } - const manifest: AgentosPackageManifest = { name: record.name }; - const agent = toRecord(record.agent); - if (typeof agent.acpEntrypoint === "string") { - manifest.agent = { - acpEntrypoint: agent.acpEntrypoint, - ...(isStringRecord(agent.env) ? { env: agent.env } : {}), - ...(Array.isArray(agent.launchArgs) && - agent.launchArgs.every((arg) => typeof arg === "string") - ? { launchArgs: agent.launchArgs } - : {}), - ...(typeof agent.snapshot === "boolean" ? { snapshot: agent.snapshot } : {}), - }; - } - return manifest; -} - -function readPackageManifestForClient( - ref: NormalizedPackageRef, -): AgentosPackageManifest | undefined { - return tryReadAgentosPackageManifest(ref.dir) ?? ref.legacyManifest; -} - -function isStringRecord(value: unknown): value is Record { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) && - Object.values(value).every((entry) => typeof entry === "string") - ); -} - type AcpResponseValue = Extract< AcpResponse, { tag: TTag } @@ -1341,7 +1267,6 @@ const RUNTIME_BOOTSTRAP_COMMANDS = [ "python", "python3", ] as const; -const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n"; const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar"); const SIDECAR_BUILD_INPUTS = [ @@ -1396,7 +1321,6 @@ function findBootstrapSeedEntry( function createKernelBootstrapLower( config: RootFilesystemConfig | undefined, - commandNames: string[], extraEntries: FilesystemEntry[] = [], ): RootSnapshotExport | null { const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false); @@ -1443,25 +1367,6 @@ function createKernelBootstrapLower( }); } - const uniqueCommands = [...new Set(commandNames)].sort((a, b) => - a.localeCompare(b), - ); - for (const command of uniqueCommands) { - const stubPath = `/bin/${command}`; - if (existingPaths.has(stubPath)) { - continue; - } - entries.push({ - path: stubPath, - type: "file", - mode: "755", - uid: 0, - gid: 0, - content: KERNEL_COMMAND_STUB, - encoding: "utf8", - }); - } - for (const entry of sortFilesystemEntries(extraEntries)) { if (existingPaths.has(entry.path)) { continue; @@ -1667,7 +1572,6 @@ async function resolveCompatLocalMounts( function collectSidecarMountPlan(options: { mounts?: MountConfig[]; - shimDir: string | null; }): { sidecarMounts: Array>; hostMounts: HostMountInfo[]; @@ -1727,17 +1631,6 @@ function collectSidecarMountPlan(options: { pushMount(mount); } - if (options.shimDir) { - pushMount({ - path: "/usr/local/bin", - plugin: createHostDirBackend({ - hostPath: options.shimDir, - readOnly: true, - }), - readOnly: true, - }); - } - hostMounts.sort((left, right) => right.vmPath.length - left.vmPath.length); hostPathMappings.sort( (left, right) => right.vmPath.length - left.vmPath.length, @@ -1745,21 +1638,6 @@ function collectSidecarMountPlan(options: { return { sidecarMounts, hostMounts, hostPathMappings }; } -function materializeToolShimDir(toolKits: ToolKit[]): string { - const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-tools-shims-")); - writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 }); - - for (const toolKit of toolKits) { - writeFileSync( - join(shimDir, `agentos-${toolKit.name}`), - KERNEL_COMMAND_STUB, - { mode: 0o755 }, - ); - } - - return shimDir; -} - function collectToolkitBootstrapCommands(toolKits: ToolKit[]): string[] { if (toolKits.length === 0) { return []; @@ -2684,12 +2562,9 @@ export class AgentOs { ); private _pendingShellExitPromises = new Set>(); private _shellCounter = 0; - /** Command names linked into `/opt/agentos/bin` at runtime (via the sidecar). */ - private _linkedCommands = new Set(); private _acpTerminals = new Map(); private _acpTerminalCounter = 0; private _softwareRoots: SoftwareRoot[]; - private _softwareAgentConfigs: Map; private _cronManager!: CronManager; private _toolKits: ToolKit[] = []; private _toolReference = ""; @@ -2711,7 +2586,6 @@ export class AgentOs { kernel: Kernel, sidecar: AgentOsSidecar, softwareRoots: SoftwareRoot[], - softwareAgentConfigs: Map, hostMounts: HostMountInfo[], env: Record, rootFilesystem: VirtualFileSystem, @@ -2726,7 +2600,6 @@ export class AgentOs { this.#kernel = kernel; this.sidecar = sidecar; this._softwareRoots = softwareRoots; - this._softwareAgentConfigs = softwareAgentConfigs; this._hostMounts = hostMounts; this._env = env; this._rootFilesystem = rootFilesystem; @@ -2770,7 +2643,7 @@ export class AgentOs { // enter VMs that run them. Unbuilt packages throw with build // instructions; opt out via defaultSoftware: false. const defaultSoftware = - options?.defaultSoftware === false ? [] : await resolveDefaultSoftware(); + options?.defaultSoftware === false ? [] : resolveDefaultSoftware(); const software: unknown[] = options?.defaultSoftware === false ? (options.software ?? []) @@ -2785,25 +2658,9 @@ export class AgentOs { }); const sidecarPackages = packageRefs.map((ref) => ({ dir: ref.dir })); // All package software is projected into `/opt/agentos` by the sidecar. The - // client stages nothing host-side; it only derives the agent configs. - const agentConfigs = new Map(); - // Register `/opt/agentos` agent packages so `createSession()` - // launches via `/opt/agentos/bin/`. The wire no longer - // carries agent metadata; read it from the package manifest. - for (const ref of packageRefs) { - const manifest = readPackageManifestForClient(ref); - if (!manifest?.agent) continue; - agentConfigs.set(manifest.name, { - adapterEntrypoint: `${OPT_AGENTOS_BIN}/${manifest.agent.acpEntrypoint}`, - launchArgs: manifest.agent.launchArgs, - defaultEnv: manifest.agent.env, - }); - } - // Agent-SDK snapshot bundle (loaded once per sidecar into the V8 startup - // snapshot, reused across sessions) for any snapshot-enabled agent. - const snapshotUserlandCode = resolveAgentSnapshotBundle( - packageRefs.map((ref) => ({ packageDir: ref.dir })), - ); + // client stages nothing host-side and parses NO package manifests: the + // sidecar owns agent resolution, agent enumeration, and agent snapshot + // bundle loading from the projected package dirs. const localMounts = await resolveCompatLocalMounts(options?.mounts); const toolKits = options?.toolKits; if (toolKits && toolKits.length > 0) { @@ -2822,17 +2679,17 @@ export class AgentOs { const toolBootstrapCommands = collectToolkitBootstrapCommands( toolKits ?? [], ); - const bootstrapLower = createKernelBootstrapLower( - options?.rootFilesystem, - [...RUNTIME_BOOTSTRAP_COMMANDS, ...toolBootstrapCommands], - ); + const bootstrapCommands = [ + ...RUNTIME_BOOTSTRAP_COMMANDS, + ...toolBootstrapCommands, + ]; + const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem); let toolReference = ""; let rootBridge: NativeSidecarKernelProxy | null = null; let kernel: Kernel | null = null; let client: SidecarProcess | null = null; let createdNativeVm: CreatedVm | null = null; let nativeSession: AuthenticatedSession | null = null; - let toolShimDir: string | null = null; let cleanedUp = false; const cleanup = async (): Promise => { @@ -2840,68 +2697,17 @@ export class AgentOs { return; } cleanedUp = true; - if (toolShimDir) { - rmSync(toolShimDir, { recursive: true, force: true }); - toolShimDir = null; - } }; try { const env: Record = getBaseEnvironment(); - if (toolKits && toolKits.length > 0) { - toolShimDir = materializeToolShimDir(toolKits); - } - // Guest command paths. The sidecar owns the `/opt/agentos` projection, - // but the client's command map (rpc-client) still needs to KNOW the - // projected command names so its shell-exec guard (`this.commands.has("sh")`) - // and wasmvm routing work. Seed each projected package's commands from its - // `bin` map (mirroring the sidecar projection's command derivation), mapped - // to their projected `/opt/agentos/bin/` path. Tool-shim commands are - // added below. + // Guest command paths. The sidecar owns the `/opt/agentos` projection and + // reports the exact projected package commands after `configureVm`. + // Tool-shim commands are added below. const commandGuestPaths = new Map(); - const deriveProjectedCommandNames = (dir: string): string[] => { - try { - const pkg = JSON.parse( - readFileSync(join(dir, "package.json"), "utf8"), - ) as { bin?: Record | string; name?: string }; - if (pkg.bin && typeof pkg.bin === "object") { - return Object.keys(pkg.bin); - } - if (typeof pkg.bin === "string") { - const base = pkg.name?.split("/").pop(); - if (base) return [base]; - } - } catch { - // no/invalid package.json — fall through to the bin/ scan - } - try { - return readdirSync(join(dir, "bin")); - } catch { - return []; - } - }; - for (const ref of packageRefs) { - for (const cmd of deriveProjectedCommandNames(ref.dir)) { - commandGuestPaths.set(cmd, `/opt/agentos/bin/${cmd}`); - } - } - const requestedMounts = options?.moduleAccessCwd - ? [ - ...(options.mounts ?? []), - { - path: "/root/node_modules", - plugin: createHostDirBackend({ - hostPath: join(options.moduleAccessCwd, "node_modules"), - readOnly: true, - }), - readOnly: true, - }, - ] - : options?.mounts; const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({ - mounts: requestedMounts, - shimDir: toolShimDir, + mounts: options?.mounts, }); // Reuse the sidecar handle's single shared native process; this VM // becomes another tenant of it rather than spawning its own process. @@ -2924,6 +2730,7 @@ export class AgentOs { permissions: sidecarPermissions, limits: options?.limits, loopbackExemptPorts: options?.loopbackExemptPorts ?? [], + bootstrapCommands, // 0.3: the Node builtin allow-list moved from configureVm to // VM creation. `undefined` => engine default allow-list; // `[]` => deny all; `[..]` => exactly those. Platform and @@ -2931,8 +2738,7 @@ export class AgentOs { // emulation), matching the prior behavior where Agent OS only // constrained the builtin allow-list. ...(options?.allowedNodeBuiltins !== undefined || - options?.highResolutionTime !== undefined || - snapshotUserlandCode !== undefined + options?.highResolutionTime !== undefined ? { jsRuntime: { platform: "node" as const, @@ -2943,9 +2749,6 @@ export class AgentOs { ...(options?.highResolutionTime !== undefined ? { highResolutionTime: options.highResolutionTime } : {}), - ...(snapshotUserlandCode !== undefined - ? { snapshotUserlandCode } - : {}), }, } : {}), @@ -2965,14 +2768,18 @@ export class AgentOs { event.ownership.vm_id === nativeVm.vmId, 10_000, ); - await client.configureVm(session, nativeVm, { + const configuredVm = await client.configureVm(session, nativeVm, { mounts: sidecarMounts, permissions: sidecarPermissions, commandPermissions: {}, loopbackExemptPorts: options?.loopbackExemptPorts, packages: sidecarPackages, packagesMountAt: OPT_AGENTOS_ROOT, + toolShimCommands: toolBootstrapCommands, }); + for (const command of configuredVm.projectedCommands) { + commandGuestPaths.set(command.name, command.guestPath); + } if (toolKits && toolKits.length > 0) { toolReference = await registerToolkitsOnSidecar( client, @@ -3084,7 +2891,6 @@ export class AgentOs { vmAdmin.kernel, sidecar, [], - agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, @@ -3280,35 +3086,6 @@ export class AgentOs { return (this.#kernel as unknown as { vfs: VirtualFileSystem }).vfs; } - private async _copyPath(from: string, to: string): Promise { - const stat = await this._vfs().lstat(from); - if (stat.isSymbolicLink) { - const target = await this._vfs().readlink(from); - await this._vfs().symlink(target, to); - return; - } - if (stat.isDirectory) { - await this._mkdirp(posixPath.dirname(to)); - if (!(await this.#kernel.exists(to))) { - await this.#kernel.mkdir(to); - } - await this._vfs().chmod(to, stat.mode); - await this._vfs().chown(to, stat.uid, stat.gid); - const entries = await this.#kernel.readdir(from); - for (const entry of entries) { - if (entry === "." || entry === "..") continue; - const fromPath = from === "/" ? `/${entry}` : `${from}/${entry}`; - const toPath = to === "/" ? `/${entry}` : `${to}/${entry}`; - await this._copyPath(fromPath, toPath); - } - return; - } - const content = await this.#kernel.readFile(from); - await this.writeFile(to, content); - await this._vfs().chmod(to, stat.mode); - await this._vfs().chown(to, stat.uid, stat.gid); - } - async readFile(path: string): Promise { this._assertSafeAbsolutePath(path); return this.#kernel.readFile(path); @@ -3390,49 +3167,36 @@ export class AgentOs { options?: ReaddirRecursiveOptions, ): Promise { this._assertSafeAbsolutePath(path); - const maxDepth = options?.maxDepth; const exclude = options?.exclude ? new Set(options.exclude) : undefined; + const entries = await this.#kernel.readdirRecursive(path, { + maxDepth: options?.maxDepth, + }); + const excludedPrefixes: string[] = []; const results: DirEntry[] = []; - // BFS queue: [dirPath, currentDepth] - const queue: [string, number][] = [[path, 0]]; - - while (queue.length > 0) { - const item = queue.shift(); - if (!item) break; - const [dirPath, depth] = item; - const entries = await this.#kernel.readdir(dirPath); - - for (const name of entries) { - if (name === "." || name === "..") continue; - if (exclude?.has(name)) continue; - - const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`; - const s = await this.#kernel.stat(fullPath); - - if (s.isSymbolicLink) { - results.push({ - path: fullPath, - type: "symlink", - size: s.size, - }); - } else if (s.isDirectory) { - results.push({ - path: fullPath, - type: "directory", - size: s.size, - }); - if (maxDepth === undefined || depth < maxDepth) { - queue.push([fullPath, depth + 1]); - } - } else { - results.push({ - path: fullPath, - type: "file", - size: s.size, - }); + for (const entry of entries) { + if ( + excludedPrefixes.some( + (prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}/`), + ) + ) { + continue; + } + if (exclude?.has(entry.name)) { + if (entry.isDirectory && !entry.isSymbolicLink) { + excludedPrefixes.push(entry.path); } + continue; } + results.push({ + path: entry.path, + type: entry.isSymbolicLink + ? "symlink" + : entry.isDirectory + ? "directory" + : "file", + size: entry.size, + }); } return results; @@ -3474,30 +3238,14 @@ export class AgentOs { } async move(from: string, to: string): Promise { - this._assertSafeAbsolutePath(from); - this._assertSafeAbsolutePath(to); - const sourceStat = await this._vfs().lstat(from); - if (!sourceStat.isDirectory || sourceStat.isSymbolicLink) { - return this.#kernel.rename(from, to); - } - await this._copyPath(from, to); - await this.delete(from, { recursive: true }); + this._assertWritableAbsolutePath(from); + this._assertWritableAbsolutePath(to); + await this.#kernel.movePath(from, to); } async delete(path: string, options?: { recursive?: boolean }): Promise { - this._assertSafeAbsolutePath(path); - const s = await this._vfs().lstat(path); - if (s.isDirectory) { - if (options?.recursive) { - const entries = await this.#kernel.readdir(path); - for (const entry of entries) { - if (entry === "." || entry === "..") continue; - await this.delete(`${path}/${entry}`, { recursive: true }); - } - } - return this.#kernel.removeDir(path); - } - return this.#kernel.removeFile(path); + this._assertWritableAbsolutePath(path); + await this.#kernel.removePath(path, { recursive: options?.recursive ?? false }); } async fetch(port: number, request: Request): Promise { @@ -3765,79 +3513,34 @@ export class AgentOs { this._sidecarVm, { dir: ref.dir }, ); - for (const command of commands) { - this._linkedCommands.add(command); - } - const manifest = readPackageManifestForClient(ref); - if (manifest?.agent) { - this._softwareAgentConfigs.set(manifest.name, { - adapterEntrypoint: `${OPT_AGENTOS_BIN}/${manifest.agent.acpEntrypoint}`, - launchArgs: manifest.agent.launchArgs, - defaultEnv: manifest.agent.env, - }); + if (this.#kernel instanceof NativeSidecarKernelProxy) { + this.#kernel.registerCommandGuestPaths( + new Map(commands.map((command) => [command.name, command.guestPath])), + ); } + // The client parses no manifests: an `agent` block in the linked package is + // picked up by the sidecar (it owns the projected `/opt/agentos` and answers + // createSession/listAgents from it). Nothing to record client-side. } - /** Returns all registered agents with their installation status. */ - listAgents(): AgentRegistryEntry[] { - // Collect agent IDs from package configs, the hardcoded configs, and the - // @agentos-software/* agent dependencies (linked lazily on first - // createSession — see createSession). - const dependencyAgents = resolveDependencyAgents(); - const allIds = new Set([ - ...this._softwareAgentConfigs.keys(), - ...dependencyAgents.keys(), - ]); - - return [...allIds] - .map((id): AgentRegistryEntry | null => { - let config = this._resolveAgentConfig(id); - if (!config) { - // Dependency agent not linked yet — report it from its manifest. - const dependencyAgent = dependencyAgents.get(id); - if (!dependencyAgent) return null; - config = { - adapterEntrypoint: `${OPT_AGENTOS_BIN}/${dependencyAgent.acpEntrypoint}`, - }; - } - - // An `/opt/agentos` agent package is materialized into the VM at - // boot, so it is always "installed" — its adapter is a real command. - if (config.adapterEntrypoint || !config.acpAdapter) { - return { - id, - adapterEntrypoint: config.adapterEntrypoint, - installed: true, - }; - } - - let installed = false; - try { - // Check the software roots that provide this adapter package. - const vmPrefix = `/root/node_modules/${config.acpAdapter}`; - let hostPkgJsonPath: string | null = null; - for (const root of this._softwareRoots) { - if (root.vmPath === vmPrefix) { - hostPkgJsonPath = join(root.hostPath, "package.json"); - break; - } - } - if (!hostPkgJsonPath) { - throw new Error("no package source"); - } - readFileSync(hostPkgJsonPath); - installed = true; - } catch { - // Package not installed - } - return { - id, - acpAdapter: config.acpAdapter, - agentPackage: config.agentPackage, - installed, - }; - }) - .filter((entry): entry is AgentRegistryEntry => entry !== null); + /** + * Returns all registered agents with their installation status. Thin forwarder: + * sends `AcpListAgentsRequest` and maps the response. The sidecar enumerates the + * projected `/opt/agentos` packages (the client parses no manifests). Every such + * agent is a package materialized into the VM, so `installed` is always `true`. + */ + async listAgents(): Promise { + const response = await this._sendAcpRequest({ + tag: "AcpListAgentsRequest", + val: { reserved: false }, + }); + if (response.tag !== "AcpListAgentsResponse") { + throw new Error(`unexpected list_agents response: ${response.tag}`); + } + return response.val.agents.map((agent) => ({ + id: agent.id, + installed: agent.installed, + })); } private _syncSessionState( @@ -4481,49 +4184,25 @@ export class AgentOs { } async createSession( - agentType: AgentType | string, + agentType: AgentType, options?: CreateSessionOptions, ): Promise<{ sessionId: string }> { - let config = this._resolveAgentConfig(agentType); - if (!config) { - // Lazily link an agent dependency on first use: agent packages are not - // projected by default (each carries a full node closure), so resolve - // the @agentos-software/* dep whose packed manifest name matches and - // link it into the running VM now. linkSoftware registers its - // entrypoint/env from the package's own agentos-package.json. - const dependencyAgent = resolveDependencyAgents().get(String(agentType)); - if (dependencyAgent) { - await this.linkSoftware({ packageDir: dependencyAgent.packageDir }); - config = this._resolveAgentConfig(agentType); - } - } - if (!config) { - throw new Error(`Unknown agent type: ${agentType}`); - } - - // System-prompt assembly and injection (launch args / OPENCODE_CONTEXTPATHS) are owned by - // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions / - // skipOsInstructions plus the agent's static launch args and env. - const launchArgs = [...(config.launchArgs ?? [])]; - const launchEnv = { ...config.defaultEnv, ...options?.env }; + // The client is npm-agnostic: it sends only the agent NAME. The sidecar + // resolves the name -> package -> entrypoint/env/launchArgs from the + // projected `/opt/agentos//current/agentos-package.json` and spawns + // (including the agent's static launch args and manifest env defaults). + // System-prompt assembly/injection (launch args / OPENCODE_CONTEXTPATHS) is + // owned by the sidecar; the host only forwards additionalInstructions / + // skipOsInstructions plus the caller's env. + const launchEnv = { ...options?.env }; const sessionCwd = options?.cwd ?? "/workspace"; - // Every agent is an `/opt/agentos` package now: the config carries a - // pre-resolved guest command path (`adapterEntrypoint`) that the sidecar - // spawns directly. There is no npm adapter resolution. - const adapterEntrypoint = config.adapterEntrypoint; - if (!adapterEntrypoint) { - throw new Error( - `agent "${String(agentType)}" config has no adapterEntrypoint`, - ); - } const response = await this._sendAcpRequest({ tag: "AcpCreateSessionRequest", val: { agentType: String(agentType), runtime: AcpRuntimeKind.JavaScript, - adapterEntrypoint, - args: launchArgs, + args: [], env: new Map(Object.entries(launchEnv)), cwd: sessionCwd, mcpServers: JSON.stringify(options?.mcpServers ?? []), @@ -4595,32 +4274,14 @@ export class AgentOs { */ async resumeSession( sessionId: string, - agentType: AgentType | string, + agentType: AgentType, options?: ResumeSessionOptions, ): Promise { - const config = this._resolveAgentConfig(agentType); - if (!config) { - throw new Error(`Unknown agent type: ${agentType}`); - } - - // Every agent is an `/opt/agentos` package now: the config carries a - // pre-resolved guest command path (`adapterEntrypoint`). There is no npm - // adapter resolution. - const adapterEntrypoint = config.adapterEntrypoint; - if (!adapterEntrypoint) { - throw new Error( - `agent "${String(agentType)}" config has no adapterEntrypoint`, - ); - } + // The client is npm-agnostic: it sends only the agent NAME. The sidecar + // resolves the name -> package -> entrypoint/env/launchArgs from the + // projected manifest, exactly as createSession does. const sessionCwd = options?.cwd ?? "/workspace"; - // The resume wire request has no dedicated `adapterEntrypoint` field; carry - // the resolved entrypoint through env under the sidecar's reserved key. The - // sidecar reads it and strips it before launching the adapter. - const launchEnv = { - ...config.defaultEnv, - ...options?.env, - [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint, - }; + const launchEnv = { ...options?.env }; const response = await this._sendAcpRequest({ tag: "AcpResumeSessionRequest", @@ -5365,15 +5026,6 @@ export class AgentOs { } } - /** - * Resolve an agent config by ID. Agents are /opt/agentos packages - * registered from their manifests (explicit software at create, or lazily - * linked dependency agents) — there is no hardcoded fallback config. - */ - private _resolveAgentConfig(agentType: string): AgentConfig | undefined { - return this._softwareAgentConfigs.get(agentType); - } - /** * Gracefully destroy a session: cancel any pending work, close the client, * and remove from tracking. Unlike close() which is abrupt, this attempts diff --git a/packages/core/src/agents.ts b/packages/core/src/agents.ts deleted file mode 100644 index f1ddaff18..000000000 --- a/packages/core/src/agents.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Agent configurations for ACP-compatible coding agents - -export interface AgentConfig { - /** - * npm package name for the ACP adapter (spawned inside the VM). Optional: an - * `/opt/agentos` agent package sets `adapterEntrypoint` instead. - */ - acpAdapter?: string; - /** npm package name for the underlying agent (optional for `/opt/agentos` packages). */ - agentPackage?: string; - /** - * Pre-resolved guest command path/name for the ACP adapter (e.g. - * `/opt/agentos/bin/`). When set, it is used directly as the - * adapter entrypoint and the npm-package resolution (`acpAdapter` → - * `/root/node_modules/...`) is bypassed. Set by `/opt/agentos` agent packages. - */ - adapterEntrypoint?: string; - /** - * Absolute host path to the software package directory that registered this - * agent config. Package-provided agent adapters should resolve their nested - * dependencies relative to this directory before falling back to the host dir - * behind the caller-supplied `/root/node_modules` mount. - */ - declaringPackageDir?: string; - /** Additional CLI args prepended when launching the ACP adapter. */ - launchArgs?: string[]; - /** - * Default env vars to pass when spawning the adapter. These are merged - * UNDER user env (lowest priority). - * Typically set by package descriptors for computed paths (e.g. PI_ACP_PI_COMMAND). - */ - defaultEnv?: Record; -} - -/** - * @deprecated npm-era legacy. Agents are `/opt/agentos` packages resolved from - * `@agentos-software/*` dependency manifests (see `default-software.ts`); this - * table is no longer consulted by agent resolution and exists only for the - * exported `AgentType` union and legacy registry-listing metadata. - */ -export const AGENT_CONFIGS = { - pi: { - acpAdapter: "@agentos-software/pi", - agentPackage: "@mariozechner/pi-coding-agent", - }, - "pi-cli": { - acpAdapter: "pi-acp", - agentPackage: "@mariozechner/pi-coding-agent", - }, - opencode: { - acpAdapter: "@agentos-software/opencode", - agentPackage: "@agentos-software/opencode", - defaultEnv: { - OPENCODE_DISABLE_CONFIG_DEP_INSTALL: "1", - OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", - }, - }, - claude: { - acpAdapter: "@agentos-software/claude-code", - agentPackage: "@anthropic-ai/claude-agent-sdk", - defaultEnv: { - CLAUDE_AGENT_SDK_CLIENT_APP: "@rivet-dev/agentos", - CLAUDE_CODE_SIMPLE: "1", - CLAUDE_CODE_FORCE_AGENT_OS_RIPGREP: "1", - CLAUDE_CODE_DEFER_GROWTHBOOK_INIT: "1", - CLAUDE_CODE_DISABLE_CWD_PERSIST: "1", - CLAUDE_CODE_DISABLE_DEV_NULL_REDIRECT: "1", - CLAUDE_CODE_NODE_SHELL_WRAPPER: "1", - CLAUDE_CODE_DISABLE_STREAM_JSON_HOOK_EVENTS: "1", - CLAUDE_CODE_SHELL: "/bin/sh", - CLAUDE_CODE_SKIP_INITIAL_MESSAGES: "1", - CLAUDE_CODE_SKIP_SANDBOX_INIT: "1", - CLAUDE_CODE_SIMPLE_SHELL_EXEC: "1", - CLAUDE_CODE_SWAP_STDIO: "0", - CLAUDE_CODE_USE_PIPE_OUTPUT: "1", - DISABLE_TELEMETRY: "1", - SHELL: "/bin/sh", - USE_BUILTIN_RIPGREP: "0", - }, - }, -} satisfies Record; - -export type AgentType = keyof typeof AGENT_CONFIGS; diff --git a/packages/core/src/cron/types.ts b/packages/core/src/cron/types.ts index 9c686a322..6ee130e73 100644 --- a/packages/core/src/cron/types.ts +++ b/packages/core/src/cron/types.ts @@ -1,5 +1,5 @@ import type { CreateSessionOptions } from "../agent-os.js"; -import type { AgentType } from "../agents.js"; +import type { AgentType } from "../types.js"; export type CronAction = | { diff --git a/packages/core/src/default-software.ts b/packages/core/src/default-software.ts index 400bdc905..09548ed0e 100644 --- a/packages/core/src/default-software.ts +++ b/packages/core/src/default-software.ts @@ -1,143 +1,12 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import type { SoftwarePackageRef } from "@agentos-software/manifest"; +import common from "@agentos-software/common"; /** - * Default software + on-demand agents — FULLY DYNAMIC, no hardcoded lists. - * - * This package's own `@agentos-software/*` runtime dependencies (from - * `dependencies`, never `devDependencies`) are the entire universe: - * - * - `resolveDefaultSoftware()` — the set a bare `AgentOs.create()` projects: - * every NON-agent dependency's registry-built descriptor (`{ packageDir }`, - * or an ARRAY for meta-packages like `@agentos-software/common`). Agent - * packages are deliberately NOT projected by default — each carries a full - * node closure (and pi a V8 snapshot bundle), so they enter a VM only when a - * session actually needs them. - * - `resolveDependencyAgents()` — the agent packages, keyed by their manifest - * `name` (the `createSession(id)` id). `createSession` links the matching - * package into the running VM on first use (`linkSoftware`), which registers - * its entrypoint/env from the package's own `agentos-package.json`. - * - * Adding a default command set or an available agent = adding a dependency. - * Resolution THROWS with build instructions instead of silently skipping: a - * missing artifact means either `pnpm install` was not run or, with the - * file-linked deps, the sibling secure-exec registry was not built. + * Default software for a bare `AgentOs.create()`: the `@agentos-software/common` + * coreutils bundle, imported like any other package. The client makes NO + * npm/node_modules assumptions (see root CLAUDE.md) — no dep scan, no + * require.resolve. Opt out with `defaultSoftware: false`; add more via `software`. */ - -/** A registry package descriptor: a package dir, or `{ packageDir }`. */ -type SoftwareDescriptor = string | { packageDir: string }; - -const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const requireFromHere = createRequire(import.meta.url); - -const BUILD_INSTRUCTIONS = - "With file-linked deps, build the registry in the sibling secure-exec " + - "checkout: `just registry-build` (see its registry/README.md)."; - -function dependencyNames(): string[] { - const manifest = JSON.parse( - readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"), - ) as { dependencies?: Record }; - return Object.keys(manifest.dependencies ?? {}) - .filter((name) => name.startsWith("@agentos-software/")) - .sort(); -} - -function descriptorDir(ref: SoftwareDescriptor): string { - return typeof ref === "string" ? ref : ref.packageDir; -} - -interface PackedManifest { - name?: string; - agent?: { acpEntrypoint?: string }; -} - -function readPackedManifest(dir: string): PackedManifest | undefined { - const path = join(dir, "agentos-package.json"); - if (!existsSync(path)) return undefined; - try { - return JSON.parse(readFileSync(path, "utf8")) as PackedManifest; - } catch { - return undefined; - } -} - -function assertBuilt(name: string, dir: string): void { - if (!existsSync(join(dir, "package.json"))) { - throw new Error( - `software package ${name} is not BUILT (missing ${dir}). ${BUILD_INSTRUCTIONS}`, - ); - } -} - -/** - * The default (non-agent) software set: every `@agentos-software/*` - * dependency's descriptor(s) whose packed manifest has no `agent` entry. - * Library deps (e.g. `@agentos-software/manifest`) export no descriptor and - * are skipped. Opt out with `AgentOs.create({ defaultSoftware: false })`. - */ -export async function resolveDefaultSoftware(): Promise { - const software: SoftwareDescriptor[] = []; - for (const name of dependencyNames()) { - let mod: { default?: unknown }; - try { - mod = (await import(name)) as { default?: unknown }; - } catch (error) { - throw new Error( - `software package ${name} could not be imported — run \`pnpm install\` ` + - `(it is a dependency of this package): ${String(error)}`, - ); - } - if (mod.default === undefined) continue; // library dep, not software - for (const descriptor of [mod.default].flat() as SoftwareDescriptor[]) { - const dir = descriptorDir(descriptor); - assertBuilt(name, dir); - if (readPackedManifest(dir)?.agent) continue; // agents load lazily - software.push(descriptor); - } - } - return software; -} - -export interface DependencyAgent { - /** The dependency package name (e.g. `@agentos-software/pi`). */ - dependency: string; - /** The packed runtime dir to `linkSoftware`. */ - packageDir: string; - /** The agent's ACP entrypoint command name. */ - acpEntrypoint: string; -} - -/** - * Agent packages among the dependencies, keyed by manifest `name` (the - * `createSession(id)` id). Sync (no module import — the packed manifest is - * read straight from `/dist/package/`). Unbuilt agent deps THROW. - */ -export function resolveDependencyAgents(): Map { - const agents = new Map(); - for (const name of dependencyNames()) { - let entry: string; - try { - entry = requireFromHere.resolve(name); // -> /dist/index.js - } catch { - continue; // not installed — surfaced by resolveDefaultSoftware/import paths - } - const dir = join(dirname(entry), "package"); - const manifest = readPackedManifest(dir); - if (!manifest?.agent) continue; - if (!manifest.name || !manifest.agent.acpEntrypoint) { - throw new Error( - `agent package ${name} has an invalid packed manifest (${dir}/agentos-package.json) — rebuild it (\`just registry-build\`)`, - ); - } - assertBuilt(name, dir); - agents.set(manifest.name, { - dependency: name, - packageDir: dir, - acpEntrypoint: manifest.agent.acpEntrypoint, - }); - } - return agents; +export function resolveDefaultSoftware(): SoftwarePackageRef[] { + return [common].flat() as SoftwarePackageRef[]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ad9e963c..eb950a102 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,6 @@ // @rivet-dev/agentos export { AgentOs, AgentOsSidecar } from "./agent-os.js"; -export { AGENT_CONFIGS } from "./agents.js"; export { CronManager, InvalidScheduleError, diff --git a/packages/core/src/options-schema.ts b/packages/core/src/options-schema.ts index 52bff77f0..b4431523d 100644 --- a/packages/core/src/options-schema.ts +++ b/packages/core/src/options-schema.ts @@ -280,7 +280,6 @@ export const agentOsOptionFieldSchemas = { highResolutionTime: z.boolean().optional(), rootFilesystem: rootFilesystemConfigSchema.optional(), mounts: z.array(mountConfigSchema).optional(), - moduleAccessCwd: z.string().optional(), additionalInstructions: z.string().optional(), scheduleDriver: z .custom((value) => typeof value === "object" && value !== null, { diff --git a/packages/core/src/packages.ts b/packages/core/src/packages.ts index 28567ff98..d0914d43f 100644 --- a/packages/core/src/packages.ts +++ b/packages/core/src/packages.ts @@ -1,9 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - type SoftwarePackageRef, - tryReadAgentosPackageManifest, -} from "./agentos-package.js"; +import type { SoftwarePackageRef } from "./agentos-package.js"; // ── Software Descriptor Types ──────────────────────────────────────── @@ -31,27 +26,3 @@ export interface SoftwareRoot { export function defineSoftware(desc: T): T { return desc; } - -/** - * Resolve the agent-SDK snapshot bundle (an esbuild IIFE at - * `/dist/sdk-snapshot.js`) for the first snapshot-enabled agent package in - * the software set. Returns its source so it can be evaluated once into the - * per-sidecar V8 startup snapshot (`jsRuntime.snapshotUserlandCode`) and reused - * across sessions. Returns `undefined` when no agent opts in (`agent.snapshot`) - * or the bundle is absent — the runtime then keeps the per-session import path. - */ -export function resolveAgentSnapshotBundle( - software: SoftwareInput[], -): string | undefined { - const descriptors = software.flat(); - for (const entry of descriptors) { - const dir = entry.packageDir; - const manifest = tryReadAgentosPackageManifest(dir); - if (!manifest?.agent?.snapshot) continue; - const bundlePath = join(dir, "dist", "sdk-snapshot.js"); - if (existsSync(bundlePath)) { - return readFileSync(bundlePath, "utf-8"); - } - } - return undefined; -} diff --git a/packages/core/src/runtime-compat.ts b/packages/core/src/runtime-compat.ts index 67128c4aa..c82c700f7 100644 --- a/packages/core/src/runtime-compat.ts +++ b/packages/core/src/runtime-compat.ts @@ -1,7 +1,6 @@ import { execFileSync } from "node:child_process"; import * as fsSync from "node:fs"; import * as fs from "node:fs/promises"; -import { tmpdir } from "node:os"; import * as path from "node:path"; import * as posixPath from "node:path/posix"; import { fileURLToPath } from "node:url"; @@ -23,6 +22,7 @@ import { type SidecarMountDescriptor, serializeMountConfigForSidecar, } from "./sidecar/rpc-client.js"; +import type { NodeModulesMountConfig } from "./host-dir-mount.js"; export const AF_INET = 2; export const AF_UNIX = 1; @@ -34,7 +34,6 @@ const S_IFREG = 0o100000; const S_IFDIR = 0o040000; const S_IFLNK = 0o120000; const MAX_SYMLINK_DEPTH = 40; -const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n"; const NODE_RUNTIME_BOOTSTRAP_COMMANDS = [ "node", "npm", @@ -361,6 +360,7 @@ export interface SystemDriver { network?: NetworkAdapter; commandExecutor?: CommandExecutor; permissions?: Permissions; + mounts: readonly NodeModulesMountConfig[]; runtime: { process: ProcessConfig; os: OSConfig; @@ -403,6 +403,14 @@ export interface KernelInterface { vfs: VirtualFileSystem; } +export interface KernelRecursiveDirEntry { + name: string; + path: string; + isDirectory: boolean; + isSymbolicLink: boolean; + size: number; +} + export interface Kernel extends KernelInterface { mount(driver: KernelRuntimeDriver): Promise; dispose(): Promise; @@ -424,11 +432,17 @@ export interface Kernel extends KernelInterface { writeFile(path: string, content: string | Uint8Array): Promise; mkdir(path: string): Promise; readdir(path: string): Promise; + readdirRecursive( + path: string, + options?: { maxDepth?: number }, + ): Promise; stat(path: string): Promise; exists(path: string): Promise; removeFile(path: string): Promise; removeDir(path: string): Promise; + removePath(path: string, options?: { recursive?: boolean }): Promise; rename(oldPath: string, newPath: string): Promise; + movePath(oldPath: string, newPath: string): Promise; readonly commands: ReadonlyMap; readonly processes: ReadonlyMap; readonly env: Record; @@ -451,18 +465,14 @@ export interface BindingTree { export type BindingFunction = (...args: unknown[]) => unknown; -export interface ModuleAccessOptions { - cwd?: string; -} - export interface NodeDriverOptions { filesystem?: VirtualFileSystem; networkAdapter?: NetworkAdapter; commandExecutor?: CommandExecutor; permissions?: Permissions; + mounts?: readonly NodeModulesMountConfig[]; processConfig?: ProcessConfig; osConfig?: OSConfig; - moduleAccess?: ModuleAccessOptions; } export interface DefaultNetworkAdapterOptions { @@ -1348,6 +1358,7 @@ export function createNodeDriver( network: options.networkAdapter, commandExecutor: options.commandExecutor, permissions: options.permissions, + mounts: options.mounts ?? [], runtime: { process: options.processConfig ?? {}, os: options.osConfig ?? {}, @@ -1642,7 +1653,6 @@ function normalizeCommandLookup(command: string): string { interface DiscoveredWasmCommandEntry { name: string; hostPath: string; - dirOffset: number; } function isWasmBinaryFile(filePath: string): boolean { @@ -1665,14 +1675,14 @@ function discoverWasmCommandEntries( ): DiscoveredWasmCommandEntry[] { const discovered: DiscoveredWasmCommandEntry[] = []; const seen = new Set(); - commandDirs.forEach((commandDir, dirOffset) => { + for (const commandDir of commandDirs) { let entries: string[]; try { entries = fsSync .readdirSync(commandDir) .sort((left, right) => left.localeCompare(right)); } catch { - return; + continue; } for (const entry of entries) { if (entry.startsWith(".")) continue; @@ -1683,7 +1693,6 @@ function discoverWasmCommandEntries( discovered.push({ name: entry, hostPath: fullPath, - dirOffset, }); continue; } @@ -1694,12 +1703,11 @@ function discoverWasmCommandEntries( discovered.push({ name: entry, hostPath: fullPath, - dirOffset, }); } } catch {} } - }); + } return discovered; } @@ -1710,7 +1718,6 @@ class WasmVmRuntimeDescriptor implements KernelRuntimeDriver { readonly commandDirs?: string[]; readonly _commandPaths = new Map(); readonly _moduleCache = new Map(); - private readonly commandDirOffsets = new Map(); constructor(options: WasmVmRuntimeOptions) { this.commandDirs = @@ -1747,21 +1754,6 @@ class WasmVmRuntimeDescriptor implements KernelRuntimeDriver { return this._commandPaths.has(normalized); } - getGuestCommandPaths(startIndex: number): ReadonlyMap { - const guestPaths = new Map(); - for (const [name] of this._commandPaths) { - const dirOffset = this.commandDirOffsets.get(name); - if (dirOffset === undefined) { - continue; - } - guestPaths.set( - name, - `/__secure_exec/commands/${startIndex + dirOffset}/${name}`, - ); - } - return guestPaths; - } - recordModuleExecution(command: string): void { const normalized = normalizeCommandLookup(command); if ( @@ -1780,11 +1772,9 @@ class WasmVmRuntimeDescriptor implements KernelRuntimeDriver { const discovered = discoverWasmCommandEntries(this.commandDirs); this.commands.length = 0; this._commandPaths.clear(); - this.commandDirOffsets.clear(); for (const entry of discovered) { this.commands.push(entry.name); this._commandPaths.set(entry.name, entry.hostPath); - this.commandDirOffsets.set(entry.name, entry.dirOffset); } } } @@ -1870,8 +1860,8 @@ function rootEntryExecutable( return kind === "file" && (mode & 0o111) !== 0; } -function createBootstrapEntries(commandNames: string[]): RootFilesystemEntry[] { - const entries: RootFilesystemEntry[] = [ +function createBootstrapEntries(): RootFilesystemEntry[] { + return [ { path: "/", kind: "directory", @@ -1899,21 +1889,6 @@ function createBootstrapEntries(commandNames: string[]): RootFilesystemEntry[] { executable: false, }, ]; - for (const command of [...new Set(commandNames)].sort((left, right) => - left.localeCompare(right), - )) { - entries.push({ - path: `/bin/${command}`, - kind: "file", - mode: 0o755, - uid: 0, - gid: 0, - content: KERNEL_COMMAND_STUB, - encoding: "utf8", - executable: true, - }); - } - return entries; } function mergeRootFilesystemEntries( @@ -2096,34 +2071,6 @@ function planNodeFilesystemPassthroughMounts( }; } -function collectGuestCommandPaths( - commandDirs: string[], - startIndex = 0, -): Map { - const guestPaths = new Map(); - for (const entry of discoverWasmCommandEntries(commandDirs)) { - if (!guestPaths.has(entry.name)) { - guestPaths.set( - entry.name, - `/__secure_exec/commands/${startIndex + entry.dirOffset}/${entry.name}`, - ); - } - } - return guestPaths; -} - -async function ensureCommandStubs( - proxy: NativeSidecarKernelProxy, - commands: Iterable, -): Promise { - const rootView = proxy.createRootView(); - for (const command of commands) { - const stubPath = `/bin/${command}`; - await rootView.writeFile(stubPath, KERNEL_COMMAND_STUB); - await rootView.chmod(stubPath, 0o755); - } -} - class DeferredFileSystem implements VirtualFileSystem { constructor(private readonly getFilesystem: () => VirtualFileSystem | null) {} @@ -2484,10 +2431,6 @@ class NativeKernel implements Kernel { private readonly pendingLocalMounts: LocalCompatMount[] = []; private mountedCommandDirs: string[] = []; private readonly mountedRuntimeDrivers: KernelRuntimeDriver[] = []; - private readonly runtimeDriverCommandDirStarts = new Map< - KernelRuntimeDriver, - number - >(); private readonly loopbackExemptPorts: number[]; constructor( @@ -2545,6 +2488,44 @@ class NativeKernel implements Kernel { return this.proxy?.zombieTimerCount ?? 0; } + private async configureRuntimeCommandStubs( + commands: Iterable, + commandDirs: string[] = this.mountedCommandDirs, + ): Promise> { + if (!this.client || !this.session || !this.vm) { + throw new Error("kernel is not ready"); + } + const sidecarMounts = commandDirs.map((commandDir, index) => + serializeMountConfigForSidecar({ + path: `/__secure_exec/commands/${index}`, + readOnly: true, + plugin: { + id: "host_dir", + config: { + hostPath: commandDir, + readOnly: true, + }, + }, + }), + ); + const localMounts = this.pendingLocalMounts.map((mount) => + serializeLocalCompatMountForSidecar(mount), + ); + const configuredVm = await this.client.configureVm(this.session, this.vm, { + mounts: [...localMounts, ...sidecarMounts], + loopbackExemptPorts: this.loopbackExemptPorts, + bootstrapCommands: [...new Set(commands)].sort((left, right) => + left.localeCompare(right), + ), + }); + return new Map( + configuredVm.projectedCommands.map((command) => [ + command.name, + command.guestPath, + ]), + ); + } + async mount(driver: KernelRuntimeDriver): Promise { await this.ensureReady(); if (!this.proxy || !this.client || !this.session || !this.vm) { @@ -2556,7 +2537,7 @@ class NativeKernel implements Kernel { this.commands.set(command, "node"); } this.mountedRuntimeDrivers.push(driver); - await ensureCommandStubs(this.proxy, driver.commands); + await this.configureRuntimeCommandStubs(driver.commands); return; } @@ -2566,43 +2547,21 @@ class NativeKernel implements Kernel { this.commands.set(command, "wasmvm"); } this.mountedRuntimeDrivers.push(driver); - await ensureCommandStubs(this.proxy, driver.commands); + await this.configureRuntimeCommandStubs(driver.commands); return; } - const startIndex = this.mountedCommandDirs.length; - const newGuestPaths = - driver.getGuestCommandPaths?.(startIndex) ?? - collectGuestCommandPaths(commandDirs, startIndex); const allCommandDirs = [...this.mountedCommandDirs, ...commandDirs]; - const sidecarMounts = allCommandDirs.map((commandDir, index) => - serializeMountConfigForSidecar({ - path: `/__secure_exec/commands/${index}`, - readOnly: true, - plugin: { - id: "host_dir", - config: { - hostPath: commandDir, - readOnly: true, - }, - }, - }), - ); - const localMounts = this.pendingLocalMounts.map((mount) => - serializeLocalCompatMountForSidecar(mount), + const projectedCommands = await this.configureRuntimeCommandStubs( + driver.commands, + allCommandDirs, ); - await this.client.configureVm(this.session, this.vm, { - mounts: [...localMounts, ...sidecarMounts], - loopbackExemptPorts: this.loopbackExemptPorts, - }); - this.proxy.registerCommandGuestPaths(newGuestPaths); + this.proxy.registerCommandGuestPaths(projectedCommands); this.mountedCommandDirs.push(...commandDirs); this.mountedRuntimeDrivers.push(driver); - this.runtimeDriverCommandDirStarts.set(driver, startIndex); - for (const command of newGuestPaths.keys()) { + for (const command of projectedCommands.keys()) { this.commands.set(command, "wasmvm"); } - await ensureCommandStubs(this.proxy, newGuestPaths.keys()); } async dispose(): Promise { @@ -2768,6 +2727,14 @@ class NativeKernel implements Kernel { return this.proxy!.readdir(targetPath); } + async readdirRecursive( + targetPath: string, + options?: { maxDepth?: number }, + ): Promise { + await this.ensureReady(); + return this.proxy!.readdirRecursive(targetPath, options); + } + async stat(targetPath: string): Promise { await this.ensureReady(); return this.proxy!.stat(targetPath); @@ -2788,11 +2755,24 @@ class NativeKernel implements Kernel { return this.proxy!.removeDir(targetPath); } + async removePath( + targetPath: string, + options?: { recursive?: boolean }, + ): Promise { + await this.ensureReady(); + return this.proxy!.removePath(targetPath, options); + } + async rename(oldPath: string, newPath: string): Promise { await this.ensureReady(); return this.proxy!.rename(oldPath, newPath); } + async movePath(oldPath: string, newPath: string): Promise { + await this.ensureReady(); + return this.proxy!.movePath(oldPath, newPath); + } + private tryResolveMountedCommand(command: string): boolean { const normalized = normalizeCommandLookup(command); for (const driver of this.mountedRuntimeDrivers) { @@ -2800,17 +2780,6 @@ class NativeKernel implements Kernel { continue; } this.commands.set(normalized, driver.kind); - if (driver.kind === "wasmvm" && this.proxy) { - const startIndex = this.runtimeDriverCommandDirStarts.get(driver); - if (startIndex !== undefined) { - const guestPaths = driver.getGuestCommandPaths?.(startIndex); - if (guestPaths?.has(normalized)) { - this.proxy.registerCommandGuestPaths( - new Map([[normalized, guestPaths.get(normalized)!]]), - ); - } - } - } return true; } return false; @@ -2860,7 +2829,7 @@ class NativeKernel implements Kernel { kind: "snapshot" as const, entries: rootFilesystemEntriesForConfig( mergeRootFilesystemEntries( - createBootstrapEntries([...NODE_RUNTIME_BOOTSTRAP_COMMANDS]), + createBootstrapEntries(), snapshotEntries, ), ), @@ -2883,6 +2852,7 @@ class NativeKernel implements Kernel { ? serializePermissionsForSidecar(bootstrapPermissions) : undefined, loopbackExemptPorts: this.loopbackExemptPorts, + bootstrapCommands: [...NODE_RUNTIME_BOOTSTRAP_COMMANDS], }; const vm = await client.createVm(session, { runtime: "java_script", diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 37512c452..8085ce600 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -264,6 +264,7 @@ export interface SystemDriver { network?: NetworkAdapter; commandExecutor?: CommandExecutor; permissions?: Permissions; + mounts: readonly NodeModulesMountConfig[]; runtime: { process: ProcessConfig; os: OSConfig; @@ -306,6 +307,14 @@ export interface KernelInterface { vfs: VirtualFileSystem; } +export interface KernelRecursiveDirEntry { + name: string; + path: string; + isDirectory: boolean; + isSymbolicLink: boolean; + size: number; +} + export interface Kernel extends KernelInterface { mount(driver: KernelRuntimeDriver): Promise; dispose(): Promise; @@ -327,11 +336,17 @@ export interface Kernel extends KernelInterface { writeFile(path: string, content: string | Uint8Array): Promise; mkdir(path: string): Promise; readdir(path: string): Promise; + readdirRecursive( + path: string, + options?: { maxDepth?: number }, + ): Promise; stat(path: string): Promise; exists(path: string): Promise; removeFile(path: string): Promise; removeDir(path: string): Promise; + removePath(path: string, options?: { recursive?: boolean }): Promise; rename(oldPath: string, newPath: string): Promise; + movePath(oldPath: string, newPath: string): Promise; readonly commands: ReadonlyMap; readonly processes: ReadonlyMap; readonly env: Record; @@ -354,11 +369,18 @@ export interface BindingTree { export type BindingFunction = (...args: unknown[]) => unknown; +export interface NodeModulesMountConfig { + path: string; + plugin: { id: "host_dir"; config: { hostPath: string; readOnly: boolean } }; + readOnly: boolean; +} + export interface NodeDriverOptions { filesystem?: VirtualFileSystem; networkAdapter?: NetworkAdapter; commandExecutor?: CommandExecutor; permissions?: Permissions; + mounts?: readonly NodeModulesMountConfig[]; processConfig?: ProcessConfig; osConfig?: OSConfig; } diff --git a/packages/core/src/sidecar/agentos-protocol.ts b/packages/core/src/sidecar/agentos-protocol.ts index ae12dd755..c61c0824e 100644 --- a/packages/core/src/sidecar/agentos-protocol.ts +++ b/packages/core/src/sidecar/agentos-protocol.ts @@ -112,7 +112,6 @@ function write2(bc: bare.ByteCursor, x: string | null): void { export type AcpCreateSessionRequest = { readonly agentType: string readonly runtime: AcpRuntimeKind - readonly adapterEntrypoint: string readonly cwd: string readonly args: readonly string[] readonly env: ReadonlyMap @@ -127,7 +126,6 @@ export function readAcpCreateSessionRequest(bc: bare.ByteCursor): AcpCreateSessi return { agentType: bare.readString(bc), runtime: readAcpRuntimeKind(bc), - adapterEntrypoint: bare.readString(bc), cwd: bare.readString(bc), args: read0(bc), env: read1(bc), @@ -142,7 +140,6 @@ export function readAcpCreateSessionRequest(bc: bare.ByteCursor): AcpCreateSessi export function writeAcpCreateSessionRequest(bc: bare.ByteCursor, x: AcpCreateSessionRequest): void { bare.writeString(bc, x.agentType) writeAcpRuntimeKind(bc, x.runtime) - bare.writeString(bc, x.adapterEntrypoint) bare.writeString(bc, x.cwd) write0(bc, x.args) write1(bc, x.env) @@ -184,6 +181,74 @@ export function writeAcpSessionRequest(bc: bare.ByteCursor, x: AcpSessionRequest write3(bc, x.params) } +/** + * Enumerate the agents available in this VM. The sidecar answers from the already + * projected `/opt/agentos` packages (client parses no manifests). + */ +export type AcpListAgentsRequest = { + readonly reserved: boolean +} + +export function readAcpListAgentsRequest(bc: bare.ByteCursor): AcpListAgentsRequest { + return { + reserved: bare.readBool(bc), + } +} + +export function writeAcpListAgentsRequest(bc: bare.ByteCursor, x: AcpListAgentsRequest): void { + bare.writeBool(bc, x.reserved) +} + +export type AcpAgentEntry = { + readonly id: string + readonly installed: boolean +} + +export function readAcpAgentEntry(bc: bare.ByteCursor): AcpAgentEntry { + return { + id: bare.readString(bc), + installed: bare.readBool(bc), + } +} + +export function writeAcpAgentEntry(bc: bare.ByteCursor, x: AcpAgentEntry): void { + bare.writeString(bc, x.id) + bare.writeBool(bc, x.installed) +} + +function read4(bc: bare.ByteCursor): readonly AcpAgentEntry[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readAcpAgentEntry(bc)] + for (let i = 1; i < len; i++) { + result[i] = readAcpAgentEntry(bc) + } + return result +} + +function write4(bc: bare.ByteCursor, x: readonly AcpAgentEntry[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeAcpAgentEntry(bc, x[i]) + } +} + +export type AcpListAgentsResponse = { + readonly agents: readonly AcpAgentEntry[] +} + +export function readAcpListAgentsResponse(bc: bare.ByteCursor): AcpListAgentsResponse { + return { + agents: read4(bc), + } +} + +export function writeAcpListAgentsResponse(bc: bare.ByteCursor, x: AcpListAgentsResponse): void { + write4(bc, x.agents) +} + export type AcpGetSessionStateRequest = { readonly sessionId: string } @@ -279,6 +344,7 @@ export type AcpRequest = | { readonly tag: "AcpCloseSessionRequest"; readonly val: AcpCloseSessionRequest } | { readonly tag: "AcpResumeSessionRequest"; readonly val: AcpResumeSessionRequest } | { readonly tag: "AcpDeliverAgentOutputRequest"; readonly val: AcpDeliverAgentOutputRequest } + | { readonly tag: "AcpListAgentsRequest"; readonly val: AcpListAgentsRequest } export function readAcpRequest(bc: bare.ByteCursor): AcpRequest { const offset = bc.offset @@ -296,6 +362,8 @@ export function readAcpRequest(bc: bare.ByteCursor): AcpRequest { return { tag: "AcpResumeSessionRequest", val: readAcpResumeSessionRequest(bc) } case 5: return { tag: "AcpDeliverAgentOutputRequest", val: readAcpDeliverAgentOutputRequest(bc) } + case 6: + return { tag: "AcpListAgentsRequest", val: readAcpListAgentsRequest(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -335,6 +403,11 @@ export function writeAcpRequest(bc: bare.ByteCursor, x: AcpRequest): void { writeAcpDeliverAgentOutputRequest(bc, x.val) break } + case "AcpListAgentsRequest": { + bare.writeU8(bc, 6) + writeAcpListAgentsRequest(bc, x.val) + break + } } } @@ -357,18 +430,18 @@ export function decodeAcpRequest(bytes: Uint8Array): AcpRequest { return result } -function read4(bc: bare.ByteCursor): u32 | null { +function read5(bc: bare.ByteCursor): u32 | null { return bare.readBool(bc) ? bare.readU32(bc) : null } -function write4(bc: bare.ByteCursor, x: u32 | null): void { +function write5(bc: bare.ByteCursor, x: u32 | null): void { bare.writeBool(bc, x != null) if (x != null) { bare.writeU32(bc, x) } } -function read5(bc: bare.ByteCursor): readonly JsonUtf8[] { +function read6(bc: bare.ByteCursor): readonly JsonUtf8[] { const len = bare.readUintSafe(bc) if (len === 0) { return [] @@ -380,7 +453,7 @@ function read5(bc: bare.ByteCursor): readonly JsonUtf8[] { return result } -function write5(bc: bare.ByteCursor, x: readonly JsonUtf8[]): void { +function write6(bc: bare.ByteCursor, x: readonly JsonUtf8[]): void { bare.writeUintSafe(bc, x.length) for (let i = 0; i < x.length; i++) { writeJsonUtf8(bc, x[i]) @@ -399,9 +472,9 @@ export type AcpSessionCreatedResponse = { export function readAcpSessionCreatedResponse(bc: bare.ByteCursor): AcpSessionCreatedResponse { return { sessionId: bare.readString(bc), - pid: read4(bc), + pid: read5(bc), modes: read3(bc), - configOptions: read5(bc), + configOptions: read6(bc), agentCapabilities: read3(bc), agentInfo: read3(bc), } @@ -409,9 +482,9 @@ export function readAcpSessionCreatedResponse(bc: bare.ByteCursor): AcpSessionCr export function writeAcpSessionCreatedResponse(bc: bare.ByteCursor, x: AcpSessionCreatedResponse): void { bare.writeString(bc, x.sessionId) - write4(bc, x.pid) + write5(bc, x.pid) write3(bc, x.modes) - write5(bc, x.configOptions) + write6(bc, x.configOptions) write3(bc, x.agentCapabilities) write3(bc, x.agentInfo) } @@ -433,11 +506,11 @@ export function writeAcpSessionRpcResponse(bc: bare.ByteCursor, x: AcpSessionRpc writeJsonUtf8(bc, x.response) } -function read6(bc: bare.ByteCursor): i32 | null { +function read7(bc: bare.ByteCursor): i32 | null { return bare.readBool(bc) ? bare.readI32(bc) : null } -function write6(bc: bare.ByteCursor, x: i32 | null): void { +function write7(bc: bare.ByteCursor, x: i32 | null): void { bare.writeBool(bc, x != null) if (x != null) { bare.writeI32(bc, x) @@ -462,11 +535,11 @@ export function readAcpSessionStateResponse(bc: bare.ByteCursor): AcpSessionStat sessionId: bare.readString(bc), agentType: bare.readString(bc), processId: bare.readString(bc), - pid: read4(bc), + pid: read5(bc), closed: bare.readBool(bc), - exitCode: read6(bc), + exitCode: read7(bc), modes: read3(bc), - configOptions: read5(bc), + configOptions: read6(bc), agentCapabilities: read3(bc), agentInfo: read3(bc), } @@ -476,11 +549,11 @@ export function writeAcpSessionStateResponse(bc: bare.ByteCursor, x: AcpSessionS bare.writeString(bc, x.sessionId) bare.writeString(bc, x.agentType) bare.writeString(bc, x.processId) - write4(bc, x.pid) + write5(bc, x.pid) bare.writeBool(bc, x.closed) - write6(bc, x.exitCode) + write7(bc, x.exitCode) write3(bc, x.modes) - write5(bc, x.configOptions) + write6(bc, x.configOptions) write3(bc, x.agentCapabilities) write3(bc, x.agentInfo) } @@ -569,6 +642,7 @@ export type AcpResponse = | { readonly tag: "AcpSessionResumedResponse"; readonly val: AcpSessionResumedResponse } | { readonly tag: "AcpErrorResponse"; readonly val: AcpErrorResponse } | { readonly tag: "AcpPendingResponse"; readonly val: AcpPendingResponse } + | { readonly tag: "AcpListAgentsResponse"; readonly val: AcpListAgentsResponse } export function readAcpResponse(bc: bare.ByteCursor): AcpResponse { const offset = bc.offset @@ -588,6 +662,8 @@ export function readAcpResponse(bc: bare.ByteCursor): AcpResponse { return { tag: "AcpErrorResponse", val: readAcpErrorResponse(bc) } case 6: return { tag: "AcpPendingResponse", val: readAcpPendingResponse(bc) } + case 7: + return { tag: "AcpListAgentsResponse", val: readAcpListAgentsResponse(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -632,6 +708,11 @@ export function writeAcpResponse(bc: bare.ByteCursor, x: AcpResponse): void { writeAcpPendingResponse(bc, x.val) break } + case "AcpListAgentsResponse": { + bare.writeU8(bc, 7) + writeAcpListAgentsResponse(bc, x.val) + break + } } } @@ -723,7 +804,7 @@ export function readAcpAgentExitedEvent(bc: bare.ByteCursor): AcpAgentExitedEven sessionId: bare.readString(bc), agentType: bare.readString(bc), processId: bare.readString(bc), - exitCode: read6(bc), + exitCode: read7(bc), restart: bare.readString(bc), restartCount: bare.readU32(bc), maxRestarts: bare.readU32(bc), @@ -734,7 +815,7 @@ export function writeAcpAgentExitedEvent(bc: bare.ByteCursor, x: AcpAgentExitedE bare.writeString(bc, x.sessionId) bare.writeString(bc, x.agentType) bare.writeString(bc, x.processId) - write6(bc, x.exitCode) + write7(bc, x.exitCode) bare.writeString(bc, x.restart) bare.writeU32(bc, x.restartCount) bare.writeU32(bc, x.maxRestarts) diff --git a/packages/core/src/sidecar/rpc-client.ts b/packages/core/src/sidecar/rpc-client.ts index a7c2502d4..04b1b53ae 100644 --- a/packages/core/src/sidecar/rpc-client.ts +++ b/packages/core/src/sidecar/rpc-client.ts @@ -540,10 +540,6 @@ export class NativeSidecarKernelProxy { const stderrChunks: Uint8Array[] = []; const effectiveCwd = options?.cwd ?? this.defaultExecCwd ?? this.cwd; const parsedCommand = parseSimpleExecCommand(command); - const resolveExecPath = (targetPath: string) => - targetPath.startsWith("/") - ? posixPath.normalize(targetPath) - : posixPath.normalize(posixPath.join(effectiveCwd, targetPath)); const runAndCapture = async ( proc: ManagedProcess, stdinOverride?: string | Uint8Array, @@ -591,54 +587,6 @@ export class NativeSidecarKernelProxy { ).toString("utf8"), }; }; - if ( - parsedCommand && - (parsedCommand[0] === "sh" || parsedCommand[0] === "/bin/sh") && - parsedCommand[1] === "-c" && - parsedCommand.length === 3 - ) { - const shellScript = parsedCommand[2].trim(); - const exitMatch = shellScript.match(/^exit(?:\s+(-?\d+))?$/); - if (exitMatch) { - return { - exitCode: Number.parseInt(exitMatch[1] ?? "0", 10), - stdout: "", - stderr: "", - }; - } - return this.exec(parsedCommand[2], options); - } - if ( - parsedCommand && - parsedCommand[0] === "chmod" && - parsedCommand.length >= 3 && - /^[0-7]{3,4}$/.test(parsedCommand[1] ?? "") - ) { - const mode = Number.parseInt(parsedCommand[1]!, 8); - for (const target of parsedCommand.slice(2)) { - await this.client.chmod( - this.session, - this.vm, - resolveExecPath(target), - mode, - ); - } - return { exitCode: 0, stdout: "", stderr: "" }; - } - if ( - parsedCommand && - parsedCommand[0] === "stat" && - parsedCommand.length === 4 && - parsedCommand[1] === "-c" && - parsedCommand[2] === "%a" - ) { - const stat = await this.stat(resolveExecPath(parsedCommand[3]!)); - return { - exitCode: 0, - stdout: `${(stat.mode & 0o777).toString(8)}\n`, - stderr: "", - }; - } const parsedCommandDriver = parsedCommand ? this.commands.get(parsedCommand[0]) : undefined; @@ -1435,6 +1383,29 @@ export class NativeSidecarKernelProxy { ); } + async readdirRecursive( + path: string, + options?: { maxDepth?: number }, + ): Promise< + Array<{ + name: string; + path: string; + isDirectory: boolean; + isSymbolicLink: boolean; + size: number; + }> + > { + const local = this.resolveLocalMount(path); + if (local) { + return this.readdirRecursiveLocal( + local.mount.fs, + local.relativePath, + options?.maxDepth, + ); + } + return this.client.readdirRecursive(this.session, this.vm, path, options); + } + async removeFile(path: string): Promise { return this.dispatchWrite( path, @@ -1451,6 +1422,48 @@ export class NativeSidecarKernelProxy { ); } + async removePath( + path: string, + options?: { recursive?: boolean }, + ): Promise { + return this.dispatchWrite( + path, + (mount, relativePath) => + this.removePathLocal(mount.fs, relativePath, options?.recursive ?? false), + () => + this.client.removePath(this.session, this.vm, path, { + recursive: options?.recursive ?? false, + }), + ); + } + + async copyPath( + fromPath: string, + toPath: string, + options?: { recursive?: boolean }, + ): Promise { + const from = this.resolveLocalMount(fromPath); + const to = this.resolveLocalMount(toPath); + if (!!from !== !!to) { + throw errnoError("EXDEV", "cross-device link not permitted"); + } + if (from && to) { + if (from.mount.path !== to.mount.path) { + throw errnoError("EXDEV", "cross-device link not permitted"); + } + this.assertLocalWritable(to.mount); + return this.copyPathLocal( + from.mount.fs, + from.relativePath, + to.relativePath, + options?.recursive ?? false, + ); + } + return this.client.copyPath(this.session, this.vm, fromPath, toPath, { + recursive: options?.recursive ?? false, + }); + } + async rename(oldPath: string, newPath: string): Promise { const from = this.resolveLocalMount(oldPath); const to = this.resolveLocalMount(newPath); @@ -1469,6 +1482,24 @@ export class NativeSidecarKernelProxy { return this.client.rename(this.session, this.vm, oldPath, newPath); } + async movePath(oldPath: string, newPath: string): Promise { + const from = this.resolveLocalMount(oldPath); + const to = this.resolveLocalMount(newPath); + + if (!!from !== !!to) { + throw errnoError("EXDEV", "cross-device link not permitted"); + } + if (from && to) { + if (from.mount.path !== to.mount.path) { + throw errnoError("EXDEV", "cross-device link not permitted"); + } + this.assertLocalWritable(from.mount); + return from.mount.fs.rename(from.relativePath, to.relativePath); + } + + return this.client.movePath(this.session, this.vm, oldPath, newPath); + } + mountFs( path: string, driver: VirtualFileSystem, @@ -2012,6 +2043,107 @@ export class NativeSidecarKernelProxy { } } + private async readdirRecursiveLocal( + fs: VirtualFileSystem, + path: string, + maxDepth: number | undefined, + ): Promise< + Array<{ + name: string; + path: string; + isDirectory: boolean; + isSymbolicLink: boolean; + size: number; + }> + > { + const results: Array<{ + name: string; + path: string; + isDirectory: boolean; + isSymbolicLink: boolean; + size: number; + }> = []; + const queue: Array<{ path: string; depth: number }> = [{ path, depth: 0 }]; + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + for (const name of await fs.readDir(current.path)) { + if (name === "." || name === "..") continue; + const child = posixPath.join(current.path, name); + const stat = await fs.lstat(child); + results.push({ + name, + path: child, + isDirectory: stat.isDirectory, + isSymbolicLink: stat.isSymbolicLink, + size: stat.size, + }); + if ( + stat.isDirectory && + !stat.isSymbolicLink && + (maxDepth === undefined || current.depth < maxDepth) + ) { + queue.push({ path: child, depth: current.depth + 1 }); + } + } + } + return results; + } + + private async removePathLocal( + fs: VirtualFileSystem, + path: string, + recursive: boolean, + ): Promise { + const stat = await fs.lstat(path); + if (stat.isDirectory && !stat.isSymbolicLink) { + if (recursive) { + for (const name of await fs.readDir(path)) { + if (name === "." || name === "..") continue; + await this.removePathLocal(fs, posixPath.join(path, name), true); + } + } + return fs.removeDir(path); + } + return fs.removeFile(path); + } + + private async copyPathLocal( + fs: VirtualFileSystem, + fromPath: string, + toPath: string, + recursive: boolean, + ): Promise { + const stat = await fs.lstat(fromPath); + if (stat.isSymbolicLink) { + return fs.symlink(await fs.readlink(fromPath), toPath); + } + if (stat.isDirectory) { + if (!recursive) { + throw errnoError("EISDIR", "illegal operation on a directory"); + } + await fs.mkdir(posixPath.dirname(toPath), { recursive: true }); + if (!(await fs.exists(toPath))) { + await fs.createDir(toPath); + } + await fs.chmod(toPath, stat.mode); + await fs.chown(toPath, stat.uid, stat.gid); + for (const name of await fs.readDir(fromPath)) { + if (name === "." || name === "..") continue; + await this.copyPathLocal( + fs, + posixPath.join(fromPath, name), + posixPath.join(toPath, name), + true, + ); + } + return; + } + await fs.writeFile(toPath, await fs.readFile(fromPath)); + await fs.chmod(toPath, stat.mode); + await fs.chown(toPath, stat.uid, stat.gid); + } + private createFilesystemView(includeLocalMounts: boolean): VirtualFileSystem { return { readFile: (path) => diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2c29184ce..410566930 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -49,7 +49,13 @@ export type { SessionModeState, SpawnedProcessInfo, } from "./agent-os.js"; -export type { AgentConfig, AgentType } from "./agents.js"; +/** + * An agent type id — the `name` of an `/opt/agentos` agent package manifest + * (e.g. `"pi"`, `"claude"`). Agents are resolved by the SIDECAR from the projected + * package manifest (`/opt/agentos//current/agentos-package.json`); the client + * passes only the name, so any manifest `name` is a valid agent type. + */ +export type AgentType = string; export type { CronAction, CronEvent, diff --git a/packages/core/tests/agent-config-environment.test.ts b/packages/core/tests/agent-config-environment.test.ts index bb63a9681..dd708bfe5 100644 --- a/packages/core/tests/agent-config-environment.test.ts +++ b/packages/core/tests/agent-config-environment.test.ts @@ -1,13 +1,10 @@ import { resolve } from "node:path"; import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; -import piCli from "@agentos-software/pi-cli"; import { describe, expect, test } from "vitest"; import { AgentOs, type AgentInfo } from "../src/agent-os.js"; -import type { AgentConfig } from "../src/agents.js"; -import type { SoftwareInput } from "../src/packages.js"; +import { createProjectedAgentPackage } from "./helpers/projected-agent-package.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); -const MOCK_ADAPTER_PATH = "/tmp/mock-agent-config-adapter.mjs"; const CAPTURED_ENV_KEYS = [ "PI_ACP_PI_COMMAND", "CLAUDE_CODE_DISABLE_CWD_PERSIST", @@ -81,44 +78,34 @@ type LaunchProbe = AgentInfo & { env?: Partial>; }; -// The new model launches `config.adapterEntrypoint` directly, so override the -// resolved config to point it at the mock adapter while preserving the config's -// env + launch args. -function useMockAdapterBin(vm: AgentOs, scriptPath: string): () => void { - const priv = vm as AgentOs & { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const config = originalConfig(id); - return config ? { ...config, adapterEntrypoint: scriptPath } : config; - }; - return () => { - priv._resolveAgentConfig = originalConfig; - }; -} - async function inspectLaunch( agentType: string, - software: SoftwareInput[] = [], + agentManifest: { + env?: Record; + launchArgs?: string[]; + } = {}, ): Promise { + const agentPackage = createProjectedAgentPackage({ + name: agentType, + adapterScript: MOCK_ACP_ADAPTER, + ...agentManifest, + }); const vm = await AgentOs.create({ + defaultSoftware: false, mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - software, + software: [agentPackage.software], }); let sessionId: string | undefined; - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); sessionId = (await vm.createSession(agentType)).sessionId; return vm.getSessionAgentInfo(sessionId) as LaunchProbe; } finally { - restore(); if (sessionId) { vm.closeSession(sessionId); } await vm.dispose(); + agentPackage.cleanup(); } } @@ -134,7 +121,9 @@ describe("agent launch args and env", () => { test("Pi CLI injects the system prompt flag and resolved pi binary", async () => { // pi-cli is still the legacy two-package CLI adapter that spawns the pi CLI // via PI_ACP_PI_COMMAND. - const agentInfo = await inspectLaunch("pi-cli", [piCli]); + const agentInfo = await inspectLaunch("pi-cli", { + env: { PI_ACP_PI_COMMAND: "pi" }, + }); expect(agentInfo.argv).toContain("--append-system-prompt"); // The {name,dir} model projects the pi CLI onto $PATH as /opt/agentos/bin/pi, @@ -144,7 +133,17 @@ describe("agent launch args and env", () => { }); test("Claude injects shell-safe launch env defaults", async () => { - const agentInfo = await inspectLaunch("claude"); + const agentInfo = await inspectLaunch("claude", { + env: { + CLAUDE_CODE_DISABLE_CWD_PERSIST: "1", + CLAUDE_CODE_DISABLE_DEV_NULL_REDIRECT: "1", + CLAUDE_CODE_NODE_SHELL_WRAPPER: "1", + CLAUDE_CODE_SHELL: "/bin/sh", + CLAUDE_CODE_SIMPLE_SHELL_EXEC: "1", + CLAUDE_CODE_SWAP_STDIO: "0", + SHELL: "/bin/sh", + }, + }); expect(agentInfo.argv).toContain("--append-system-prompt"); expect(agentInfo.env).toMatchObject({ diff --git a/packages/core/tests/agentos-package-agent-vm.test.ts b/packages/core/tests/agentos-package-agent-vm.test.ts index ab5588dda..838c54aef 100644 --- a/packages/core/tests/agentos-package-agent-vm.test.ts +++ b/packages/core/tests/agentos-package-agent-vm.test.ts @@ -109,12 +109,14 @@ describe("agentos agent package (VM)", () => { if (root) rmSync(root, { recursive: true, force: true }); }); - test("lists the packaged agent as installed", () => { - const agents = vm.listAgents(); + test("lists the packaged agent as installed", async () => { + // listAgents() is a sidecar ACP RPC: the sidecar enumerates the projected + // `/opt/agentos` packages. The entry is just id + installed (no client-side + // entrypoint resolution). + const agents = await vm.listAgents(); const entry = agents.find((a) => a.id === "mock-agent"); expect(entry).toBeDefined(); expect(entry?.installed).toBe(true); - expect(entry?.adapterEntrypoint).toBe("/opt/agentos/bin/mock-agent-acp"); }); test("createSession launches the packaged agent via /opt/agentos/bin", async () => { diff --git a/packages/core/tests/agentos-protocol.test.ts b/packages/core/tests/agentos-protocol.test.ts index 234206b4b..7d7b04c9d 100644 --- a/packages/core/tests/agentos-protocol.test.ts +++ b/packages/core/tests/agentos-protocol.test.ts @@ -13,7 +13,6 @@ describe("agent-os ACP protocol", () => { val: { agentType: "codex", runtime: AcpRuntimeKind.JavaScript, - adapterEntrypoint: "/root/node_modules/agent/adapter.mjs", cwd: "/home/agentos", args: ["--model", "gpt-5"], env: new Map([["SECURE_EXEC_KEEP_STDIN_OPEN", "1"]]), diff --git a/packages/core/tests/claude-session.test.ts b/packages/core/tests/claude-session.test.ts index 1c9b675d7..f19f9111d 100644 --- a/packages/core/tests/claude-session.test.ts +++ b/packages/core/tests/claude-session.test.ts @@ -25,7 +25,6 @@ import { // `xu` is a registry VM-test binary that ships in no package — project it via // a synthesized test-only package (throws if the native build output lacks it). const TEST_COMMAND_SOFTWARE = testOnlyCommandSoftware(["xu"]); -import { AGENT_CONFIGS } from "../src/agents.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); const XU_COMMAND = "xu hello-agent-os"; @@ -111,20 +110,6 @@ function createToolFixtures(toolCall: ToolCall, finalText: string): Fixture[] { ]; } -test("Claude config defaults to /bin/sh and V8-safe shell env flags", () => { - const expectedEnv = { - CLAUDE_CODE_DISABLE_CWD_PERSIST: "1", - CLAUDE_CODE_DISABLE_DEV_NULL_REDIRECT: "1", - CLAUDE_CODE_NODE_SHELL_WRAPPER: "1", - CLAUDE_CODE_SHELL: "/bin/sh", - CLAUDE_CODE_SIMPLE_SHELL_EXEC: "1", - CLAUDE_CODE_SWAP_STDIO: "0", - SHELL: "/bin/sh", - }; - - expect(AGENT_CONFIGS.claude.defaultEnv).toMatchObject(expectedEnv); -}); - async function writeAsyncSpawnScript(vm: AgentOs): Promise { await vm.writeFile(NODE_ASYNC_SPAWN_SCRIPT_PATH, NODE_ASYNC_SPAWN_SCRIPT); } diff --git a/packages/core/tests/helpers/projected-agent-package.ts b/packages/core/tests/helpers/projected-agent-package.ts new file mode 100644 index 000000000..d70eaf736 --- /dev/null +++ b/packages/core/tests/helpers/projected-agent-package.ts @@ -0,0 +1,67 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SoftwarePackageRef } from "../../src/agentos-package.js"; + +export interface ProjectedAgentPackageOptions { + name: string; + adapterScript: string; + acpEntrypoint?: string; + env?: Record; + launchArgs?: string[]; +} + +export interface ProjectedAgentPackage { + packageDir: string; + software: SoftwarePackageRef; + binPath: string; + cleanup(): void; +} + +export function createProjectedAgentPackage( + options: ProjectedAgentPackageOptions, +): ProjectedAgentPackage { + const root = mkdtempSync(join(tmpdir(), "agentos-projected-agent-")); + const packageDir = join(root, "pkg"); + const binDir = join(packageDir, "bin"); + const acpEntrypoint = + options.acpEntrypoint ?? `${options.name.replace(/[^a-zA-Z0-9_-]/g, "-")}-acp`; + + mkdirSync(binDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ name: options.name, version: "1.0.0" }, null, 2), + ); + writeFileSync( + join(packageDir, "agentos-package.json"), + JSON.stringify( + { + name: options.name, + agent: { + acpEntrypoint, + ...(options.env ? { env: options.env } : {}), + ...(options.launchArgs ? { launchArgs: options.launchArgs } : {}), + }, + }, + null, + 2, + ), + ); + + const binPath = join(binDir, acpEntrypoint); + writeFileSync(binPath, `#!/usr/bin/env node\n${options.adapterScript}\n`); + chmodSync(binPath, 0o755); + + return { + packageDir, + software: { packageDir }, + binPath, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} diff --git a/packages/core/tests/list-agents.test.ts b/packages/core/tests/list-agents.test.ts index 787dd86a0..161a866fa 100644 --- a/packages/core/tests/list-agents.test.ts +++ b/packages/core/tests/list-agents.test.ts @@ -1,57 +1,67 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; -import { AGENT_CONFIGS } from "../src/agents.js"; +// `listAgents()` is a sidecar ACP RPC: the sidecar enumerates the projected +// `/opt/agentos` packages (those whose `agentos-package.json` carries an +// `agent.acpEntrypoint`). The client parses NO manifests and makes no npm/ +// node_modules assumptions. A bare `create()` projects no agent packages. describe("listAgents()", () => { let vm: AgentOs; + let root: string; - beforeEach(async () => { - vm = await AgentOs.create(); - }); + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "agentos-list-agents-")); + // Two self-contained /opt/agentos agent packages projected via `software`. + for (const name of ["alpha-agent", "beta-agent"]) { + const pkgDir = join(root, name); + mkdirSync(join(pkgDir, "bin"), { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name, version: "1.0.0" }, null, 2), + ); + writeFileSync( + join(pkgDir, "agentos-package.json"), + JSON.stringify( + { name, agent: { acpEntrypoint: `${name}-acp` } }, + null, + 2, + ), + ); + const binPath = join(pkgDir, "bin", `${name}-acp`); + writeFileSync(binPath, "#!/usr/bin/env node\n"); + chmodSync(binPath, 0o755); + } + vm = await AgentOs.create({ + defaultSoftware: false, + software: [join(root, "alpha-agent"), join(root, "beta-agent")], + }); + }, 60_000); - afterEach(async () => { - await vm.dispose(); + afterAll(async () => { + await vm?.dispose(); + if (root) rmSync(root, { recursive: true, force: true }); }); - test("returns the shipped built-in agents", () => { - const agents = vm.listAgents(); + test("lists the projected agent packages, sorted by id", async () => { + const agents = await vm.listAgents(); const ids = agents.map((a) => a.id); - expect(ids).toContain("pi"); - expect(ids).toContain("pi-cli"); - expect(ids).toContain("opencode"); - expect(ids).toContain("claude"); - }); - - test("each entry exposes the current built-in adapter metadata", () => { - const agents = vm.listAgents(); - for (const [id, config] of Object.entries(AGENT_CONFIGS)) { - const agent = agents.find((entry) => entry.id === id); - expect(agent).toBeDefined(); - expect(agent?.acpAdapter).toBe(config.acpAdapter); - expect(agent?.agentPackage).toBe(config.agentPackage); - expect(typeof agent?.installed).toBe("boolean"); - } - }); - - test("installed is true when adapter package exists", () => { - const agents = vm.listAgents(); - for (const id of Object.keys(AGENT_CONFIGS)) { - expect(agents.find((agent) => agent.id === id)?.installed).toBe(true); - } + expect(ids).toContain("alpha-agent"); + expect(ids).toContain("beta-agent"); }); - test("installed is false when adapter package is missing", async () => { - // Create a VM with no /root/node_modules mount, so no adapter packages - // are resolvable and every agent must report installed: false. - const vm2 = await AgentOs.create({}); - try { - const agents = vm2.listAgents(); - // No packages installed in /tmp - for (const agent of agents) { - expect(agent.installed).toBe(false); - } - } finally { - await vm2.dispose(); + test("every projected agent package is installed", async () => { + const agents = await vm.listAgents(); + for (const agent of agents) { + expect(agent.installed).toBe(true); } }); }); diff --git a/packages/core/tests/migration-parity.test.ts b/packages/core/tests/migration-parity.test.ts index ff5d29123..ee02a3cf4 100644 --- a/packages/core/tests/migration-parity.test.ts +++ b/packages/core/tests/migration-parity.test.ts @@ -5,22 +5,10 @@ import common from "@agentos-software/common"; import { afterEach, describe, expect, test } from "vitest"; import { z } from "zod"; import { AgentOs, hostTool, toolKit } from "../src/index.js"; -import type { AgentConfig } from "../src/agents.js"; +import { createProjectedAgentPackage } from "./helpers/projected-agent-package.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); -const MOCK_ADAPTER_PATH = "/tmp/mock-migration-parity-adapter.mjs"; const textDecoder = new TextDecoder(); -const SYNTHETIC_AGENT = { - name: "migration-parity-agent", - type: "agent" as const, - packageDir: MODULE_ACCESS_CWD, - requires: [], - agent: { - id: "migration-parity", - acpAdapter: "migration-parity-adapter", - agentPackage: "migration-parity-agent", - }, -}; const MOCK_ACP_ADAPTER = ` let buffer = ""; @@ -189,22 +177,6 @@ function getRequestPath(req: IncomingMessage): string { return req.url ?? "/"; } -function useMockAdapterBin(vm: AgentOs, scriptPath: string): () => void { - const priv = vm as AgentOs & { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const c = originalConfig(id); - return c - ? { ...c, adapterEntrypoint: scriptPath } - : { adapterEntrypoint: scriptPath }; - }; - return () => { - priv._resolveAgentConfig = originalConfig; - }; -} - describe("native sidecar migration parity gate", () => { const cleanups = new Set<() => Promise>(); @@ -386,9 +358,18 @@ describe("native sidecar migration parity gate", () => { }, 60_000); test("covers session lifecycle and agent prompt flow on the Rust sidecar path", async () => { + const agentPackage = createProjectedAgentPackage({ + name: "migration-parity", + adapterScript: MOCK_ACP_ADAPTER, + }); + cleanups.add(async () => { + agentPackage.cleanup(); + }); + const vm = await AgentOs.create({ mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - software: [SYNTHETIC_AGENT], + defaultSoftware: false, + software: [agentPackage.software], permissions: { fs: "allow", childProcess: "allow", @@ -400,12 +381,6 @@ describe("native sidecar migration parity gate", () => { }); assertNativeSidecar(vm); - const restoreAdapter = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); - cleanups.add(async () => { - restoreAdapter(); - }); - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); - const { sessionId } = await vm.createSession("migration-parity"); const events: { method: string; params?: unknown }[] = []; diff --git a/packages/core/tests/os-instructions.test.ts b/packages/core/tests/os-instructions.test.ts index 4f8aa383f..6886fbe33 100644 --- a/packages/core/tests/os-instructions.test.ts +++ b/packages/core/tests/os-instructions.test.ts @@ -2,7 +2,10 @@ import * as fs from "node:fs"; import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; -import type { AgentConfig } from "../src/agents.js"; +import { + createProjectedAgentPackage, + type ProjectedAgentPackage, +} from "./helpers/projected-agent-package.js"; const OS_INSTRUCTIONS_FIXTURE = resolve( import.meta.dirname, @@ -87,197 +90,137 @@ process.stdin.on('data', (chunk) => { describe("createSession OS instructions integration", () => { let vm: AgentOs; + let agentPackages: ProjectedAgentPackage[]; beforeEach(async () => { + agentPackages = ["pi", "opencode"].map((name) => + createProjectedAgentPackage({ name, adapterScript: MOCK_ACP_ADAPTER }), + ); vm = await AgentOs.create({ defaultSoftware: false, + software: agentPackages.map((agentPackage) => agentPackage.software), }); }); afterEach(async () => { await vm.dispose(); + for (const agentPackage of agentPackages) { + agentPackage.cleanup(); + } }); - /** - * Patch _resolveAgentConfig to inject a mock adapter entrypoint instead of - * launching the real `/opt/agentos` adapter. - */ - function useMockAdapterBin(scriptPath: string): () => void { - const priv = vm as unknown as { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const c = originalConfig(id); - return c - ? { ...c, adapterEntrypoint: scriptPath } - : { adapterEntrypoint: scriptPath }; - }; - return () => { - priv._resolveAgentConfig = originalConfig; + test("createSession with PI passes --append-system-prompt in spawn args", async () => { + const { sessionId } = await vm.createSession("pi"); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; }; - } + const argv = agentInfo.argv ?? []; - test("createSession with PI passes --append-system-prompt in spawn args", async () => { - const scriptPath = "/tmp/mock-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const { sessionId } = await vm.createSession("pi"); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; - - expect(argv).toContain("--append-system-prompt"); - const argIdx = argv.indexOf("--append-system-prompt"); - const instructionsArg = argv[argIdx + 1]; - expect(instructionsArg).toBeTruthy(); - expect(instructionsArg.length).toBeGreaterThan(0); - // The sidecar injects the embedded base prompt, not a guest-read file. - expect(instructionsArg).toContain("# agentOS"); - - vm.closeSession(sessionId); - } finally { - restore(); - } + expect(argv).toContain("--append-system-prompt"); + const argIdx = argv.indexOf("--append-system-prompt"); + const instructionsArg = argv[argIdx + 1]; + expect(instructionsArg).toBeTruthy(); + expect(instructionsArg.length).toBeGreaterThan(0); + // The sidecar injects the embedded base prompt, not a guest-read file. + expect(instructionsArg).toContain("# agentOS"); + + vm.closeSession(sessionId); }); test("createSession with OpenCode passes the sidecar-materialized prompt path in OPENCODE_CONTEXTPATHS", async () => { - const scriptPath = "/tmp/mock-opencode-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const { sessionId } = await vm.createSession("opencode"); - - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - contextPaths?: string; - argv?: string[]; - }; - const contextPaths = JSON.parse(agentInfo.contextPaths as string); - expect(agentInfo.argv ?? []).not.toContain("acp"); - // The base prompt is injected through a sidecar-materialized file, not the old baked path. - expect(contextPaths).toContain("/tmp/agentos-system-prompt.md"); - expect(contextPaths).not.toContain("/etc/agentos/instructions.md"); - // Default opencode repo-relative markers are still present. - expect(contextPaths).toContain("CLAUDE.md"); - expect(contextPaths).toContain("opencode.md"); - - // The materialized prompt file holds the base prompt text. - const promptData = await vm.readFile("/tmp/agentos-system-prompt.md"); - const promptText = new TextDecoder().decode(promptData); - expect(promptText).toContain("# agentOS"); - - // No .agent-os/ directory created in cwd - const agentOsDirExists = await vm.exists("/home/agentos/.agent-os"); - expect(agentOsDirExists).toBe(false); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const { sessionId } = await vm.createSession("opencode"); + + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + contextPaths?: string; + argv?: string[]; + }; + const contextPaths = JSON.parse(agentInfo.contextPaths as string); + expect(agentInfo.argv ?? []).not.toContain("acp"); + // The base prompt is injected through a sidecar-materialized file, not the old baked path. + expect(contextPaths).toContain("/tmp/agentos-system-prompt.md"); + expect(contextPaths).not.toContain("/etc/agentos/instructions.md"); + // Default opencode repo-relative markers are still present. + expect(contextPaths).toContain("CLAUDE.md"); + expect(contextPaths).toContain("opencode.md"); + + // The materialized prompt file holds the base prompt text. + const promptData = await vm.readFile("/tmp/agentos-system-prompt.md"); + const promptText = new TextDecoder().decode(promptData); + expect(promptText).toContain("# agentOS"); + + // No .agent-os/ directory created in cwd + const agentOsDirExists = await vm.exists("/home/agentos/.agent-os"); + expect(agentOsDirExists).toBe(false); + + vm.closeSession(sessionId); }); test("createSession with skipOsInstructions:true does not inject args or env", async () => { - const scriptPath = "/tmp/mock-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const { sessionId } = await vm.createSession("pi", { - skipOsInstructions: true, - }); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; - - expect(argv).not.toContain("--append-system-prompt"); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const { sessionId } = await vm.createSession("pi", { + skipOsInstructions: true, + }); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; + }; + const argv = agentInfo.argv ?? []; + + expect(argv).not.toContain("--append-system-prompt"); + + vm.closeSession(sessionId); }); test("createSession with skipOsInstructions:true still forwards additionalInstructions", async () => { - const scriptPath = "/tmp/mock-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - const additionalText = "CUSTOM_MARKER: skip base, keep extras."; - try { - const { sessionId } = await vm.createSession("pi", { - skipOsInstructions: true, - additionalInstructions: additionalText, - }); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; - expect(instructionsArg).toContain(additionalText); - expect(instructionsArg).not.toContain("# agentOS"); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const { sessionId } = await vm.createSession("pi", { + skipOsInstructions: true, + additionalInstructions: additionalText, + }); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; + }; + const argv = agentInfo.argv ?? []; + + const argIdx = argv.indexOf("--append-system-prompt"); + expect(argIdx).toBeGreaterThan(-1); + const instructionsArg = argv[argIdx + 1]; + expect(instructionsArg).toContain(additionalText); + expect(instructionsArg).not.toContain("# agentOS"); + + vm.closeSession(sessionId); }); test("user-provided env vars override instruction env vars", async () => { - const scriptPath = "/tmp/mock-opencode-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const userContextPaths = '["my-custom-paths.md"]'; - const { sessionId } = await vm.createSession("opencode", { - env: { OPENCODE_CONTEXTPATHS: userContextPaths }, - }); - - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - contextPaths?: string; - }; - expect(agentInfo.contextPaths).toBe(userContextPaths); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const userContextPaths = '["my-custom-paths.md"]'; + const { sessionId } = await vm.createSession("opencode", { + env: { OPENCODE_CONTEXTPATHS: userContextPaths }, + }); + + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + contextPaths?: string; + }; + expect(agentInfo.contextPaths).toBe(userContextPaths); + + vm.closeSession(sessionId); }); test("additionalInstructions content appears in injected text", async () => { - const scriptPath = "/tmp/mock-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - const additionalText = "CUSTOM_MARKER: Always use pnpm for this project."; - try { - const { sessionId } = await vm.createSession("pi", { - additionalInstructions: additionalText, - }); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; - expect(instructionsArg).toContain(additionalText); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const { sessionId } = await vm.createSession("pi", { + additionalInstructions: additionalText, + }); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; + }; + const argv = agentInfo.argv ?? []; + + const argIdx = argv.indexOf("--append-system-prompt"); + expect(argIdx).toBeGreaterThan(-1); + const instructionsArg = argv[argIdx + 1]; + expect(instructionsArg).toContain(additionalText); + + vm.closeSession(sessionId); }); test("AgentOs.create additionalInstructions are included in created sessions", async () => { @@ -287,27 +230,20 @@ describe("createSession OS instructions integration", () => { vm = await AgentOs.create({ defaultSoftware: false, additionalInstructions: vmLevelInstructions, + software: agentPackages.map((agentPackage) => agentPackage.software), }); - const scriptPath = "/tmp/mock-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const { sessionId } = await vm.createSession("pi"); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; - expect(instructionsArg).toContain(vmLevelInstructions); - - vm.closeSession(sessionId); - } finally { - restore(); - } + const { sessionId } = await vm.createSession("pi"); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; + }; + const argv = agentInfo.argv ?? []; + + const argIdx = argv.indexOf("--append-system-prompt"); + expect(argIdx).toBeGreaterThan(-1); + const instructionsArg = argv[argIdx + 1]; + expect(instructionsArg).toContain(vmLevelInstructions); + + vm.closeSession(sessionId); }); }); diff --git a/packages/core/tests/public-api-exports.test.ts b/packages/core/tests/public-api-exports.test.ts index 754ecdabb..0430ae164 100644 --- a/packages/core/tests/public-api-exports.test.ts +++ b/packages/core/tests/public-api-exports.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "vitest"; import { - AGENT_CONFIGS, AgentOs, AgentOsSidecar, CronManager, @@ -54,7 +53,6 @@ describe("root public API exports", () => { test("re-exports the main public value surface from the root entrypoint", () => { expect(AgentOs).toBeTypeOf("function"); expect(AgentOsSidecar).toBeTypeOf("function"); - expect(AGENT_CONFIGS).toBeTypeOf("object"); expect(CronManager).toBeTypeOf("function"); expect(TimerScheduleDriver).toBeTypeOf("function"); expect(createHostDirBackend).toBeTypeOf("function"); diff --git a/packages/core/tests/session-id-collision.test.ts b/packages/core/tests/session-id-collision.test.ts index cc0fb218d..8a75f80b9 100644 --- a/packages/core/tests/session-id-collision.test.ts +++ b/packages/core/tests/session-id-collision.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import type { JsonRpcNotification } from "../src/index.js"; import { AgentOs } from "../src/index.js"; import { encodeAcpEvent } from "../src/sidecar/agentos-protocol.js"; +import { + createProjectedAgentPackage, + type ProjectedAgentPackage, +} from "./helpers/projected-agent-package.js"; // agent-os.ts keeps this namespace as a module-private const; mirror the literal. const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp"; @@ -84,24 +88,30 @@ function sessionUpdateNotification(text: string): string { describe("colliding adapter sessionId isolation (I.4 / J.4)", () => { let vm: AgentOs | null = null; + let agentPackage: ProjectedAgentPackage | null = null; afterEach(async () => { await vm?.dispose(); vm = null; + agentPackage?.cleanup(); + agentPackage = null; }); test("a second createSession returning a colliding sessionId must not orphan the first session's handlers", async () => { - vm = await AgentOs.create({ defaultSoftware: false }); + agentPackage = createProjectedAgentPackage({ + name: "mock", + adapterScript: "process.stdin.resume();", + }); + vm = await AgentOs.create({ + defaultSoftware: false, + software: [agentPackage.software], + }); const internal = vm as unknown as { - _resolveAgentConfig(t: string): unknown; _sendAcpRequest(req: { tag: string }): Promise; }; // The adapter (untrusted) always reports the same sessionId. - vi.spyOn(internal, "_resolveAgentConfig").mockReturnValue({ - adapterEntrypoint: "/root/node_modules/@mock/adapter/bin.js", - }); vi.spyOn(internal, "_sendAcpRequest").mockImplementation( async (req: { tag: string }) => { if (req.tag === "AcpCreateSessionRequest") { @@ -159,16 +169,19 @@ describe("colliding adapter sessionId isolation (I.4 / J.4)", () => { }); test("resumeSession returning a colliding live sessionId is rejected without orphaning handlers", async () => { - vm = await AgentOs.create({ defaultSoftware: false }); + agentPackage = createProjectedAgentPackage({ + name: "mock", + adapterScript: "process.stdin.resume();", + }); + vm = await AgentOs.create({ + defaultSoftware: false, + software: [agentPackage.software], + }); const internal = vm as unknown as { - _resolveAgentConfig(t: string): unknown; _sendAcpRequest(req: { tag: string }): Promise; }; - vi.spyOn(internal, "_resolveAgentConfig").mockReturnValue({ - adapterEntrypoint: "/root/node_modules/@mock/adapter/bin.js", - }); vi.spyOn(internal, "_sendAcpRequest").mockImplementation( async (req: { tag: string }) => { if (req.tag === "AcpCreateSessionRequest") { diff --git a/packages/core/tests/session-resume.test.ts b/packages/core/tests/session-resume.test.ts index 13c675c5b..7f0de591b 100644 --- a/packages/core/tests/session-resume.test.ts +++ b/packages/core/tests/session-resume.test.ts @@ -1,9 +1,6 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; -import type { AgentConfig } from "../src/agents.js"; -import type { SoftwareInput } from "../src/packages.js"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; +import { createProjectedAgentPackage } from "./helpers/projected-agent-package.js"; // L2 (agent-os side): exercise the sidecar resume orchestration state machine // end-to-end against the REAL agentos-sidecar with a MOCK ACP adapter (no LLM). @@ -17,21 +14,6 @@ import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; // - Tier 2 (fallback): `session/new`, mode "fallback", new live id, and a // continuation preamble prepended to the next `session/prompt`. -const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); -const MOCK_ADAPTER_PATH = "/tmp/mock-session-resume-adapter.mjs"; - -const SYNTHETIC_AGENT = { - name: "session-resume-mock", - type: "agent" as const, - packageDir: MODULE_ACCESS_CWD, - requires: [], - agent: { - id: "synthetic", - acpAdapter: "session-resume-mock-adapter", - agentPackage: "session-resume-mock-agent", - }, -}; - // Single configurable mock ACP adapter. Its behavior is selected at launch time // via the `MOCK_RESUME_SCENARIO` env var, which the host forwards through // `resumeSession(..., { env })` (the sidecar passes `env` straight to the @@ -169,38 +151,30 @@ process.stdin.on("data", (chunk) => { }); `; -function useMockAdapterBin(vm: AgentOs, scriptPath: string): () => void { - const priv = vm as AgentOs & { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const c = originalConfig(id); - return c - ? { ...c, adapterEntrypoint: scriptPath } - : { adapterEntrypoint: scriptPath }; - }; - return () => { - priv._resolveAgentConfig = originalConfig; - }; -} - -async function createMockAgentVm(software: SoftwareInput[]): Promise { - return AgentOs.create({ - mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - software, +async function createMockAgentVm(): Promise<{ + vm: AgentOs; + cleanup(): void; +}> { + const agentPackage = createProjectedAgentPackage({ + name: "synthetic", + adapterScript: MOCK_ACP_ADAPTER, + }); + const vm = await AgentOs.create({ + defaultSoftware: false, + software: [agentPackage.software], }); + return { + vm, + cleanup: agentPackage.cleanup, + }; } describe("sidecar resume orchestration (mock ACP adapter)", () => { test("Tier 1 native: loadSession advertised + session/load ok -> mode native, id preserved", async () => { - const vm = await createMockAgentVm([SYNTHETIC_AGENT]); - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + const { vm, cleanup } = await createMockAgentVm(); let liveSessionId: string | undefined; try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); - const externalSessionId = "external-session-native"; const result = await vm.resumeSession(externalSessionId, "synthetic", { env: { MOCK_RESUME_SCENARIO: "native" }, @@ -211,22 +185,19 @@ describe("sidecar resume orchestration (mock ACP adapter)", () => { // Native load reuses the requested id: external == live. expect(result.sessionId).toBe(externalSessionId); } finally { - restore(); if (liveSessionId) { vm.closeSession(liveSessionId); } await vm.dispose(); + cleanup(); } }); test("Tier 1 native: resume advertised + session/resume ok -> mode native, id preserved", async () => { - const vm = await createMockAgentVm([SYNTHETIC_AGENT]); - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + const { vm, cleanup } = await createMockAgentVm(); let liveSessionId: string | undefined; try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); - const externalSessionId = "external-session-resume-only"; const result = await vm.resumeSession(externalSessionId, "synthetic", { env: { MOCK_RESUME_SCENARIO: "resume-only" }, @@ -236,22 +207,19 @@ describe("sidecar resume orchestration (mock ACP adapter)", () => { expect(result.mode).toBe("native"); expect(result.sessionId).toBe(externalSessionId); } finally { - restore(); if (liveSessionId) { vm.closeSession(liveSessionId); } await vm.dispose(); + cleanup(); } }); test("unknown_session fallthrough: session/load NotFoundError -> mode fallback, new live id, preamble prepended", async () => { - const vm = await createMockAgentVm([SYNTHETIC_AGENT]); - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + const { vm, cleanup } = await createMockAgentVm(); let liveSessionId: string | undefined; try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); - const externalSessionId = "external-session-fallthrough"; const transcriptPath = "/root/.agentos/threads/external-session-fallthrough.md"; const result = await vm.resumeSession(externalSessionId, "synthetic", { @@ -294,22 +262,19 @@ describe("sidecar resume orchestration (mock ACP adapter)", () => { expect(secondBlocks.length).toBe(1); expect(secondBlocks[0].text).toBe("second turn"); } finally { - restore(); if (liveSessionId) { vm.closeSession(liveSessionId); } await vm.dispose(); + cleanup(); } }); test("no loadSession capability -> straight to fallback (session/new)", async () => { - const vm = await createMockAgentVm([SYNTHETIC_AGENT]); - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + const { vm, cleanup } = await createMockAgentVm(); let liveSessionId: string | undefined; try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); - const externalSessionId = "external-session-nocap"; const result = await vm.resumeSession(externalSessionId, "synthetic", { env: { MOCK_RESUME_SCENARIO: "no-loadsession" }, @@ -326,11 +291,11 @@ describe("sidecar resume orchestration (mock ACP adapter)", () => { expect(blocks.length).toBe(1); expect(blocks[0].text).toBe("hello"); } finally { - restore(); if (liveSessionId) { vm.closeSession(liveSessionId); } await vm.dispose(); + cleanup(); } }); }); diff --git a/packages/core/tests/synthetic-session-updates.test.ts b/packages/core/tests/synthetic-session-updates.test.ts index 527642e93..8db25631b 100644 --- a/packages/core/tests/synthetic-session-updates.test.ts +++ b/packages/core/tests/synthetic-session-updates.test.ts @@ -1,23 +1,6 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "vitest"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { AgentOs } from "../src/agent-os.js"; -import type { AgentConfig } from "../src/agents.js"; -import type { SoftwareInput } from "../src/packages.js"; - -const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); -const MOCK_ADAPTER_PATH = "/tmp/mock-synthetic-session-updates-adapter.mjs"; -const SYNTHETIC_AGENT = { - name: "synthetic-session-updates", - type: "agent" as const, - packageDir: MODULE_ACCESS_CWD, - requires: [], - agent: { - id: "synthetic", - acpAdapter: "synthetic-session-updates-adapter", - agentPackage: "synthetic-session-updates-agent", - }, -}; +import { createProjectedAgentPackage } from "./helpers/projected-agent-package.js"; const MOCK_ACP_ADAPTER = ` let buffer = ""; @@ -146,37 +129,19 @@ process.stdin.on("data", (chunk) => { }); `; -function useMockAdapterBin(vm: AgentOs, scriptPath: string): () => void { - const priv = vm as AgentOs & { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const c = originalConfig(id); - return c - ? { ...c, adapterEntrypoint: scriptPath } - : { adapterEntrypoint: scriptPath }; - }; - return () => { - priv._resolveAgentConfig = originalConfig; - }; -} - -async function createMockAgentVm(software: SoftwareInput[]): Promise { - return AgentOs.create({ - mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - software, - }); -} - describe("synthetic session/update compatibility", () => { test("surfaces synthetic mode and config updates when the ACP adapter omits notifications", async () => { - const vm = await createMockAgentVm([SYNTHETIC_AGENT]); - const restore = useMockAdapterBin(vm, MOCK_ADAPTER_PATH); + const agentPackage = createProjectedAgentPackage({ + name: "synthetic", + adapterScript: MOCK_ACP_ADAPTER, + }); + const vm = await AgentOs.create({ + defaultSoftware: false, + software: [agentPackage.software], + }); let sessionId: string | undefined; try { - await vm.writeFile(MOCK_ADAPTER_PATH, MOCK_ACP_ADAPTER); sessionId = (await vm.createSession("synthetic")).sessionId; const receivedEvents: string[] = []; @@ -214,11 +179,11 @@ describe("synthetic session/update compatibility", () => { ).length, ).toBeGreaterThanOrEqual(2); } finally { - restore(); if (sessionId) { vm.closeSession(sessionId); } await vm.dispose(); + agentPackage.cleanup(); } }); }); diff --git a/packages/core/tests/tool-reference.test.ts b/packages/core/tests/tool-reference.test.ts index 0f305b675..d92f9b6dc 100644 --- a/packages/core/tests/tool-reference.test.ts +++ b/packages/core/tests/tool-reference.test.ts @@ -1,11 +1,10 @@ -import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { z } from "zod"; import { AgentOs, hostTool, toolKit } from "../src/index.js"; -import type { AgentConfig } from "../src/agents.js"; - -const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); +import { + createProjectedAgentPackage, + type ProjectedAgentPackage, +} from "./helpers/projected-agent-package.js"; /** * Mock ACP adapter that answers initialize/session/new and echoes its launch argv in agentInfo so @@ -70,34 +69,25 @@ const mathToolKit = toolKit({ describe("tool reference registration", () => { let vm: AgentOs; + let agentPackage: ProjectedAgentPackage; beforeEach(async () => { + agentPackage = createProjectedAgentPackage({ + name: "pi", + adapterScript: MOCK_ACP_ADAPTER, + }); vm = await AgentOs.create({ - mounts: moduleAccessMounts(MODULE_ACCESS_CWD), + defaultSoftware: false, + software: [agentPackage.software], toolKits: [mathToolKit], }); }); afterEach(async () => { await vm.dispose(); + agentPackage.cleanup(); }); - function useMockAdapterBin(scriptPath: string): () => void { - const priv = vm as unknown as { - _resolveAgentConfig: (id: string) => AgentConfig | undefined; - }; - const originalConfig = priv._resolveAgentConfig.bind(priv); - priv._resolveAgentConfig = (id: string) => { - const c = originalConfig(id); - return c - ? { ...c, adapterEntrypoint: scriptPath } - : { adapterEntrypoint: scriptPath }; - }; - return () => { - priv._resolveAgentConfig = originalConfig; - }; - } - test("stores generated tool reference markdown on the VM", () => { const toolReference = (vm as unknown as { _toolReference: string }) ._toolReference; @@ -115,27 +105,19 @@ describe("tool reference registration", () => { }); test("createSession injects the registered tool reference into the system prompt", async () => { - const scriptPath = "/tmp/mock-tool-reference-adapter.mjs"; - await vm.writeFile(scriptPath, MOCK_ACP_ADAPTER); - const restore = useMockAdapterBin(scriptPath); - - try { - const { sessionId } = await vm.createSession("pi"); - const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; - }; - const argv = agentInfo.argv ?? []; + const { sessionId } = await vm.createSession("pi"); + const agentInfo = vm.getSessionAgentInfo(sessionId) as { + argv?: string[]; + }; + const argv = agentInfo.argv ?? []; - const argIndex = argv.indexOf("--append-system-prompt"); - expect(argIndex).toBeGreaterThan(-1); - const prompt = argv[argIndex + 1]; - expect(prompt).toContain("## Available Host Tools"); - expect(prompt).toContain("`agentos-math add --a --b `"); - expect(prompt).toContain("### math"); + const argIndex = argv.indexOf("--append-system-prompt"); + expect(argIndex).toBeGreaterThan(-1); + const prompt = argv[argIndex + 1]; + expect(prompt).toContain("## Available Host Tools"); + expect(prompt).toContain("`agentos-math add --a --b `"); + expect(prompt).toContain("### math"); - vm.closeSession(sessionId); - } finally { - restore(); - } + vm.closeSession(sessionId); }); }); diff --git a/packages/secure-exec-example-ai-agent-type-check/src/index.ts b/packages/secure-exec-example-ai-agent-type-check/src/index.ts index 53a62a444..69a7e25a7 100644 --- a/packages/secure-exec-example-ai-agent-type-check/src/index.ts +++ b/packages/secure-exec-example-ai-agent-type-check/src/index.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { anthropic } from "@ai-sdk/anthropic"; import { createTypeScriptTools } from "@secure-exec/typescript"; import { generateText, stepCountIs, tool } from "ai"; @@ -8,15 +9,14 @@ import { createNodeDriver, createNodeRuntime, createNodeRuntimeDriverFactory, + nodeModulesMount, } from "secure-exec"; import { z } from "zod"; const filesystem = createInMemoryFileSystem(); const systemDriver = createNodeDriver({ filesystem, - moduleAccess: { - cwd: process.cwd(), - }, + mounts: [nodeModulesMount(join(process.cwd(), "node_modules"))], permissions: allowAll, }); const runtimeDriverFactory = createNodeRuntimeDriverFactory(); diff --git a/packages/secure-exec-typescript/src/index.ts b/packages/secure-exec-typescript/src/index.ts index 74cee8b95..da5213b29 100644 --- a/packages/secure-exec-typescript/src/index.ts +++ b/packages/secure-exec-typescript/src/index.ts @@ -94,9 +94,14 @@ type CompilerResponse = type RuntimeCompilerEnvelope = | { ok: true; result: CompilerResponse } | { ok: false; errorMessage?: string }; +interface RuntimeNodeModulesMount { + guestPath: string; + hostPath: string; +} const DEFAULT_COMPILER_SPECIFIER = "typescript"; const moduleRequire = createRequire(import.meta.url); +const GUEST_NODE_PATH_DELIMITER = ":"; let nextRuntimeRequestId = 0; export function createTypeScriptTools( @@ -165,8 +170,8 @@ async function runCompilerInRuntime( ); } - const hostNodeModules = findNearestNodeModules(process.cwd()); - if (!hostNodeModules) { + const nodeModulesMount = resolveNodeModulesMount(options); + if (!nodeModulesMount) { throw new Error( "Unable to locate host node_modules for TypeScript runtime", ); @@ -190,7 +195,7 @@ async function runCompilerInRuntime( } catch {} } - return runCompilerWithKernelRuntime(options, request, hostNodeModules); + return runCompilerWithKernelRuntime(options, request, nodeModulesMount); } async function runCompilerWithRuntimeDriver( @@ -222,7 +227,7 @@ function isUnavailableRuntimeDriverError(error: unknown): boolean { async function runCompilerWithKernelRuntime( options: TypeScriptToolsOptions, request: CompilerRequest, - hostNodeModules: string, + nodeModulesMount: RuntimeNodeModulesMount, ): Promise { const filesystem = options.systemDriver.filesystem; if (!filesystem) { @@ -244,12 +249,12 @@ async function runCompilerWithKernelRuntime( const kernel = createKernel({ filesystem, permissions: normalizeKernelPermissions(options.systemDriver.permissions), - env: buildRuntimeEnv(options), + env: buildRuntimeEnv(options, nodeModulesMount.guestPath), cwd: request.options.cwd ?? "/root", mounts: [ { - path: "/node_modules", - fs: new NodeFileSystem({ root: hostNodeModules }), + path: nodeModulesMount.guestPath, + fs: new NodeFileSystem({ root: nodeModulesMount.hostPath }), readOnly: true, }, ], @@ -326,6 +331,28 @@ function findNearestNodeModules(startDir: string): string | null { } } +function resolveNodeModulesMount( + options: TypeScriptToolsOptions, +): RuntimeNodeModulesMount | null { + for (const mount of options.systemDriver.mounts) { + const config = mount.plugin.config; + if ( + mount.plugin.id === "host_dir" && + config && + typeof config.hostPath === "string" && + mount.path.endsWith("/node_modules") + ) { + return { + guestPath: mount.path, + hostPath: config.hostPath, + }; + } + } + + const hostPath = findNearestNodeModules(process.cwd()); + return hostPath ? { guestPath: "/node_modules", hostPath } : null; +} + function createFailureResult( kind: CompilerRequest["kind"], errorMessage?: string, @@ -371,8 +398,12 @@ function normalizeCompilerFailureMessage(errorMessage?: string): string { function buildRuntimeEnv( options: TypeScriptToolsOptions, + nodeModulesGuestPath: string, ): Record { const env = { ...(options.systemDriver.runtime.process.env ?? {}) }; + env.NODE_PATH = [env.NODE_PATH, nodeModulesGuestPath] + .filter(Boolean) + .join(GUEST_NODE_PATH_DELIMITER); if (options.memoryLimit !== undefined) { const limit = Math.max(1, Math.floor(options.memoryLimit)); env.NODE_OPTIONS = [env.NODE_OPTIONS, `--max-old-space-size=${limit}`] diff --git a/packages/secure-exec-typescript/tests/typescript-tools.integration.test.ts b/packages/secure-exec-typescript/tests/typescript-tools.integration.test.ts index 7cd5a0714..6f21d176e 100644 --- a/packages/secure-exec-typescript/tests/typescript-tools.integration.test.ts +++ b/packages/secure-exec-typescript/tests/typescript-tools.integration.test.ts @@ -1,4 +1,4 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createTypeScriptTools } from "@secure-exec/typescript"; import { @@ -8,6 +8,7 @@ import { createNodeDriver, createNodeRuntime, createNodeRuntimeDriverFactory, + nodeModulesMount, type NodeRuntimeDriverFactory, } from "secure-exec"; import { describe, expect, it } from "vitest"; @@ -23,7 +24,7 @@ function createTools() { tools: createTypeScriptTools({ systemDriver: createNodeDriver({ filesystem, - moduleAccess: { cwd: workspaceRoot }, + mounts: [nodeModulesMount(join(workspaceRoot, "node_modules"))], permissions: allowAllFs, }), runtimeDriverFactory: createNodeRuntimeDriverFactory(), @@ -214,7 +215,7 @@ describe("@secure-exec/typescript", () => { const brokenTools = createTypeScriptTools({ systemDriver: createNodeDriver({ filesystem: createInMemoryFileSystem(), - moduleAccess: { cwd: workspaceRoot }, + mounts: [nodeModulesMount(join(workspaceRoot, "node_modules"))], permissions: allowAllFs, }), runtimeDriverFactory: createNodeRuntimeDriverFactory(), diff --git a/packages/secure-exec/src/index.ts b/packages/secure-exec/src/index.ts index b60f095ad..9036ec427 100644 --- a/packages/secure-exec/src/index.ts +++ b/packages/secure-exec/src/index.ts @@ -31,6 +31,7 @@ export type { TimingMitigation, VirtualFileSystem, } from "@rivet-dev/agentos-core/internal/runtime-compat"; +export type { NodeModulesMountConfig } from "@rivet-dev/agentos-core"; export { allowAll, allowAllChildProcess, @@ -54,3 +55,4 @@ export { rename, stat, } from "@rivet-dev/agentos-core/internal/runtime-compat"; +export { nodeModulesMount } from "@rivet-dev/agentos-core";