diff --git a/CLAUDE.md b/CLAUDE.md index f8fde28cf6..5d357e3242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,11 @@ ACP/session layer, AgentOS client APIs, docs, and publish machinery. The shimmed public surface. - Keep root `package.json` scripts limited to Turbo orchestration; repo-specific commands belong in `justfile` recipes or scoped package scripts. +- AgentOS targets native Linux/container execution. Browser support is not + needed or supported here: browser sources may remain as dormant reference + code, but their entrypoints must stay disabled and they must not enter default + builds, CI, publication, or behavioral-parity requirements without a + separately approved design. ## Security Model @@ -163,9 +168,9 @@ custom host-syscall imports. Treat that target as **native POSIX**; - Signal delivery must use the bounded/coalesced session broker and must never spawn an OS thread per delivered signal. Embedded V8, standalone WASM, and Python must share sidecar reactor capabilities rather than own parallel - networking implementations. Browser runtime sources remain in-tree but are - disabled from default builds, CI, and publication until a separate design is - approved. + networking implementations. Browser runtime sources remain in-tree only as + dormant reference code; browser entrypoints and support remain disabled until + a separate design is approved. - The architecture and migration contract are specified in `docs/design/unified-sidecar-runtime.md`. diff --git a/Cargo.toml b/Cargo.toml index 7d91d1a5fb..21d9804f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,9 @@ [workspace] resolver = "2" -exclude = ["software"] +exclude = ["software", "crates/agentos-sidecar-core"] members = [ "crates/actor-uds-client", "crates/agentos-protocol", - "crates/agentos-sidecar-core", "crates/agentos-sidecar", "crates/agentos-sidecar-browser", "crates/client", @@ -29,7 +28,6 @@ members = [ # workspace commands so dormant browser code cannot block native CI. default-members = [ "crates/agentos-protocol", - "crates/agentos-sidecar-core", "crates/agentos-sidecar", "crates/client", "crates/bridge", @@ -69,7 +67,6 @@ agentos-native-sidecar-core = { path = "crates/native-sidecar-core", version = " agentos-runtime = { path = "crates/runtime", version = "0.0.1" } agentos-protocol = { path = "crates/agentos-protocol", version = "0.0.1" } agentos-sidecar-client = { path = "crates/sidecar-client", version = "0.0.1" } -agentos-sidecar-core = { path = "crates/agentos-sidecar-core", version = "0.0.1" } agentos-sidecar = { path = "crates/agentos-sidecar", version = "0.0.1" } agentos-sidecar-browser = { path = "crates/agentos-sidecar-browser", version = "0.0.1" } agentos-sidecar-protocol = { path = "crates/sidecar-protocol", version = "0.0.1" } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000..048046cd7a --- /dev/null +++ b/TODO.md @@ -0,0 +1,148 @@ +# Native-only simplification TODO + +This list tracks the architecture cleanup that precedes the durable AgentOS +session API. The canonical session design is maintained separately at +`~/.agents/specs/agentos-session-api.md`; session persistence, history, adapter +restoration, and the shared SQLite layer belong to that follow-up revision. + +## 1. Use one native ACP orchestration path + +**Status:** complete. + +`crates/agentos-sidecar/src/acp_extension.rs` is the only product ACP +orchestrator. The older `agentos-sidecar-core` implementation is retained only +as dormant browser reference source: it is excluded from the workspace, +default builds, and publication. Browser entrypoints remain disabled. + +The native path keeps the existing explicit restore state machine and is async. +An adapter exit is terminal: AgentOS evicts the dead live route, emits a typed +exit event, and requires the caller to restore explicitly. It never respawns an +adapter or replays a prompt automatically. The durable session revision will +replace this transient orchestration with the specified session store and +adapter pump without reintroducing a second product path. + +- [x] Exclude the old core from native builds and publication. +- [x] Keep create, restore, prompt, cancel, and close on the native extension. +- [x] Remove implicit adapter restart and request replay. +- [x] Preserve native resume and unknown-session fallback behavior for the + upcoming durable-session migration. +- [x] Cover terminal adapter exit and explicit retry behavior in + `acp_adapter_stderr.rs`. +- [x] Guard the single native product path in `architecture_guards.rs`. + +## 2. Remove adapter-specific policy from shared ACP code + +**Status:** complete. + +The shared sidecar no longer branches on Claude, Codex, Pi, Pi CLI, or OpenCode +names. It assembles the AgentOS system prompt once and forwards it through the +adapter-neutral `ACP_APPEND_SYSTEM_PROMPT` launch contract. AgentOS-owned +package launchers translate that value into each upstream adapter's required +flag or context file while continuing to invoke the real upstream SDK adapter. + +- [x] Remove adapter-name branches for prompt injection and config defaults. +- [x] Move Pi, Pi CLI, Claude, and OpenCode launch compatibility into their + AgentOS-owned launchers. +- [x] Keep ACP capabilities and payload handling adapter-neutral. +- [x] Add a source guard preventing adapter-name policy from returning. +- [x] Add integration coverage proving the generic launch contract reaches the + adapter and preserves assembled base/additional/binding instructions. + +## 3. Disable browser support + +**Status:** complete. + +AgentOS targets native Linux/container behavior. Browser sources stay in the +repository for reference, but their Rust and TypeScript public entrypoints are +disabled. Browser crates and packages remain outside default builds, CI, +publication, and compatibility-mirror publication. + +- [x] Disable browser Rust and TypeScript public entrypoints without deleting + reference source. +- [x] Document the native-only boundary in `CLAUDE.md`. +- [x] Guard source retention plus build/publication exclusion. + +## 4. Remove duplicate TypeScript in-memory filesystems + +**Status:** complete. + +The Rust sidecar VFS is the production AgentOS filesystem. The duplicate +in-memory filesystem, overlay filesystem, and in-memory layer store were +removed from `@rivet-dev/agentos-core` and its public exports. The low-level +runtime compatibility API now requires a caller-owned filesystem rather than +creating a default. + +One implementation remains at the explicit +`@rivet-dev/agentos-runtime-core/test-runtime` test surface for repository test +fixtures and benchmarks that exercise host VFS callbacks. Disabled browser +sources remain dormant reference code, not a supported production VFS. + +- [x] Remove the core in-memory VFS and overlay/layer-store implementations. +- [x] Remove their root and secure-exec compatibility exports. +- [x] Require a caller-owned filesystem in the low-level compatibility runtime. +- [x] Move remaining repository fixtures to the explicit test-only surface. +- [x] Remove obsolete duplicate semantic tests. +- [x] Add a source guard preventing a production AgentOS TypeScript VFS default + or implementation from returning. + +## 5. Remove the client transport event log + +**Status:** complete. + +The Rust sidecar transport correlates pending responses and fans out live events +through a bounded broadcast channel. It retains no `WireEventLog`, replay +history, global/route sequence state, or provisional process ownership. Durable +ACP history belongs to the sidecar-owned SQLite session store in the follow-up +session revision, not in a client transport. + +- [x] Remove client-side retained event history and route sequencing. +- [x] Keep bounded live event delivery with typed broadcast lag. +- [x] Prove an event arriving before its response remains live-delivered. +- [x] Prove overflow reports lag instead of retaining/replaying history. +- [x] Add a source guard preventing transport history from returning. + +## 6. Remove filesystem and terminal operations from ACP + +**Status:** pending; do separately from the session API. + +ACP should carry agent session input/output, permissions, and lifecycle rather +than acting as a second filesystem or process-execution capability plane. +Before removal, audit real upstream adapters to identify any use of ACP +`fs/*`/`terminal/*` requests and ensure necessary behavior exists through normal +AgentOS runtime tools. + +- [ ] Inventory advertised capabilities, inbound handlers, callback routes, and + retained terminal state. +- [ ] Characterize which upstream adapters depend on these requests. +- [ ] Stop advertising filesystem and terminal client capabilities. +- [ ] Reject adapter-initiated `fs/*` and `terminal/*` methods with a typed + method-not-supported response. +- [ ] Remove obsolete handlers, state, callbacks, and success-path tests. +- [ ] Add a guard preventing the alternate capability plane from returning. + +## 7. Remove cron from AgentOS completely + +**Status:** pending; do separately from the session API. + +Scheduling is an application, actor, container, or infrastructure concern. +Remove the AgentOS scheduler and its TypeScript/Rust APIs, actor actions/events, +protocol messages, configuration, state, examples, docs, tests, and generated +compatibility surfaces. External schedulers should wake the relevant actor/VM +and submit an ordinary AgentOS request. + +- [ ] Inventory the complete cron surface and characterize current behavior. +- [ ] Remove scheduler implementation, protocol, client, actor, and state + ownership. +- [ ] Remove examples, website pages, compatibility exports, and cron-only + tests. +- [ ] Document the external scheduler boundary. +- [ ] Add a guard preventing AgentOS cron ownership from returning. + +## Non-goals + +- Do not move client policy into another duplicate sidecar subsystem when Linux + or ACP already supplies the behavior. +- Do not edit or replace third-party adapter implementations. +- Do not restore browser support implicitly as part of native ACP work. +- Do not weaken bounds, ownership enforcement, typed failures, or error + propagation while simplifying the implementation. diff --git a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare index edbb678870..fa09428a77 100644 --- a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare +++ b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare @@ -168,14 +168,10 @@ type AcpAgentStderrEvent struct { # Emitted when the ACP adapter process exits without an explicit close_session — # a crash from the host's perspective (any spontaneous exit, including code 0). -# `restart` reports the sidecar's bounded auto-restart outcome: -# "restarted" — adapter respawned and the session was natively re-attached -# (session/load | session/resume) under the same sessionId; -# the session stays live. -# "unsupported" — the respawned adapter does not advertise a native resume -# capability; the session record was evicted. -# "failed" — the respawn or the native re-attach errored; evicted. -# "exhausted" — maxRestarts was already spent for this session; evicted. +# `restart` is "not_attempted": the sidecar never respawns an adapter or +# replays an interrupted request implicitly. `restartCount` and `maxRestarts` +# are therefore both zero. Explicit session restoration is a separate caller +# operation. # `exitCode` is absent when the exit was observed indirectly (e.g. a write to # the adapter's stdin failed because the process was already gone). type AcpAgentExitedEvent struct { diff --git a/crates/agentos-sidecar-browser/Cargo.toml b/crates/agentos-sidecar-browser/Cargo.toml index 022a2b8641..22b35f404d 100644 --- a/crates/agentos-sidecar-browser/Cargo.toml +++ b/crates/agentos-sidecar-browser/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["cdylib", "rlib"] # (native: tokio/mio + native agentos-native-sidecar). Real ACP logic comes from a # future host-free agentos-sidecar-core. agentos-protocol = { workspace = true } -agentos-sidecar-core = { workspace = true } +agentos-sidecar-core = { path = "../agentos-sidecar-core" } agentos-bridge = { workspace = true } agentos-native-sidecar-browser = { workspace = true } diff --git a/crates/agentos-sidecar-browser/src/lib.rs b/crates/agentos-sidecar-browser/src/lib.rs index 5e9169ffa0..87563b91c4 100644 --- a/crates/agentos-sidecar-browser/src/lib.rs +++ b/crates/agentos-sidecar-browser/src/lib.rs @@ -1,5 +1,7 @@ #![forbid(unsafe_code)] +// AGENTOS_BROWSER_SUPPORT_DISABLED: retained for reference, but AgentOS is native-only. +/* //! Agent OS browser sidecar wrapper. #[cfg(target_arch = "wasm32")] @@ -239,3 +241,4 @@ mod tests { } } } +*/ diff --git a/crates/agentos-sidecar-core/Cargo.toml b/crates/agentos-sidecar-core/Cargo.toml index ee5d8c57b5..999d5fa042 100644 --- a/crates/agentos-sidecar-core/Cargo.toml +++ b/crates/agentos-sidecar-core/Cargo.toml @@ -1,15 +1,16 @@ [package] name = "agentos-sidecar-core" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true +version = "0.0.1" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/rivet-dev/agent-os" description = "Host-free Agent OS sidecar core (ACP state machine), shared by the native and wasm/browser sidecars" +publish = false # Host-free ONLY: no tokio, no std::fs/host IO, no native agentos-native-sidecar. # This crate must compile to wasm32 so the browser sidecar can run the ACP logic. [dependencies] -agentos-protocol = { workspace = true } +agentos-protocol = { path = "../agentos-protocol", version = "0.0.1" } serde = { version = "1.0", features = ["derive"] } serde_bare = "0.5" serde_json = "1.0" diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index 52757ce11e..21967d7b50 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -7,9 +7,8 @@ use std::time::{Duration, Instant}; use agentos_native_sidecar::extension::ExtensionSnapshot; use agentos_native_sidecar::limits::DEFAULT_ACP_MAX_READ_LINE_BYTES; use agentos_native_sidecar::wire::{ - CloseStdinRequest, EventPayload, ExecuteRequest, GuestFilesystemCallRequest, - GuestFilesystemOperation, GuestRuntimeKind, KillProcessRequest, OwnershipScope, StreamChannel, - WriteStdinRequest, + CloseStdinRequest, EventPayload, ExecuteRequest, GuestRuntimeKind, KillProcessRequest, + OwnershipScope, StreamChannel, WriteStdinRequest, }; use agentos_native_sidecar::{ Extension, ExtensionContext, ExtensionFuture, ExtensionInterruptRequest, @@ -48,20 +47,10 @@ const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; /// freshly created ones. const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = "{\"fs\":{\"readTextFile\":true,\"writeTextFile\":true},\"terminal\":true}"; -const OPENCODE_SYSTEM_PROMPT_PATH: &str = "/tmp/agentos-system-prompt.md"; -const OPENCODE_DEFAULT_CONTEXT_PATHS: [&str; 11] = [ - ".github/copilot-instructions.md", - ".cursorrules", - ".cursor/rules/", - "CLAUDE.md", - "CLAUDE.local.md", - "opencode.md", - "opencode.local.md", - "OpenCode.md", - "OpenCode.local.md", - "OPENCODE.md", - "OPENCODE.local.md", -]; +/// Adapter-neutral contract between the shared ACP runtime and an +/// AgentOS-owned package launcher. The launcher translates this text into the +/// upstream adapter's native flag, SDK option, or context-file mechanism. +const ACP_APPEND_SYSTEM_PROMPT_ENV: &str = "ACP_APPEND_SYSTEM_PROMPT"; // Embedded next to this source so `cargo publish` packages it (an out-of-crate // `include_str!` path breaks the isolated package-verify build). The TypeScript // side reads the same file from this location for its sanity check. @@ -84,20 +73,10 @@ const ADAPTER_EXITED_ERROR_MARKER: &str = "exited with code"; /// (the exit is observed lazily, on the next stdin write), so it is classified /// as an adapter-gone failure alongside `ADAPTER_EXITED_ERROR_MARKER`. const ADAPTER_NO_ACTIVE_PROCESS_MARKER: &str = "has no active process"; -/// Bounded auto-restart budget per ACP session. Each unexpected adapter exit -/// consumes one attempt (respawn + native `session/load`/`session/resume` under -/// the same session id); once spent, further exits evict the session record, -/// matching the pre-restart behavior. Every attempt and the exhaustion of the -/// budget are logged at `warn` and surfaced to the host via -/// `AcpAgentExitedEvent`, so the cap is observable rather than a silent retry -/// loop. -const MAX_ADAPTER_RESTARTS: u32 = 3; -/// `AcpAgentExitedEvent.restart` outcome strings. Keep in sync with the schema -/// doc on `agent_os_acp_v1.bare` and the TypeScript `AgentRestartOutcome` type. -const ADAPTER_RESTART_OUTCOME_RESTARTED: &str = "restarted"; -const ADAPTER_RESTART_OUTCOME_UNSUPPORTED: &str = "unsupported"; -const ADAPTER_RESTART_OUTCOME_FAILED: &str = "failed"; -const ADAPTER_RESTART_OUTCOME_EXHAUSTED: &str = "exhausted"; +/// `AcpAgentExitedEvent.restart` outcome for the native runtime. AgentOS never +/// respawns adapters or replays requests implicitly; restoration is an +/// explicit session operation initiated by the caller. +const ADAPTER_RESTART_OUTCOME_NOT_ATTEMPTED: &str = "not_attempted"; #[derive(Debug, Default)] pub struct AcpExtension { @@ -129,49 +108,6 @@ struct AcpSessionRecord { /// then cleared. See `CONTINUATION_PREAMBLE` and the resume state machine on /// `AcpExtension::resume_session`. pending_preamble: Option, - /// Launch + handshake parameters retained for adapter auto-restart. See - /// `AdapterRestartState`. - restart: AdapterRestartState, -} - -/// Adapter launch + handshake parameters retained on the session record so the -/// sidecar can auto-restart a crashed adapter (see -/// `AcpExtension::handle_adapter_exit`). `args`/`env` are the FINAL values that -/// went into the original `ExecuteRequest` — post prompt-injection, including -/// `AGENTOS_KEEP_STDIN_OPEN` — so a restart relaunches exactly what -/// create/resume launched. `count` is the restarts already consumed for this -/// session, bounded by `MAX_ADAPTER_RESTARTS`. -#[derive(Debug, Clone)] -struct AdapterRestartState { - runtime: AcpRuntimeKind, - entrypoint: String, - args: Vec, - env: BTreeMap, - cwd: String, - protocol_version: i32, - client_capabilities: String, - count: u32, -} - -/// Why an adapter auto-restart attempt did not leave the session live. Maps to -/// the `restart` outcome string on `AcpAgentExitedEvent`. -#[derive(Debug)] -enum AdapterRestartError { - /// The respawned adapter does not advertise a native resume capability - /// (`loadSession`/`resume`), so the session cannot be re-attached under its - /// existing id. - Unsupported, - /// The respawn, handshake, or native re-attach failed. - Failed(SidecarError), -} - -impl AdapterRestartError { - fn detail(&self) -> String { - match self { - Self::Unsupported => String::from("adapter does not advertise loadSession/resume"), - Self::Failed(error) => error.to_string(), - } - } } impl AcpExtension { @@ -283,27 +219,9 @@ impl AcpExtension { 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 - { - return AcpHandlerOutput::response(Err(error)); - } + self.apply_prompt_injection(&request, &mut env); tracing::info!(target: "agentos_sidecar::perf", phase = "prompt_injection", elapsed_ms = __t0.elapsed().as_millis() as u64, "create_session phase"); - // Retain the final launch + handshake parameters for adapter - // auto-restart before they are moved into the spawn request. - let restart_state = AdapterRestartState { - runtime: request.runtime.clone(), - entrypoint: resolved.entrypoint.clone(), - args: args.clone(), - env: env.clone(), - cwd: request.cwd.clone(), - protocol_version: request.protocol_version, - client_capabilities: request.client_capabilities.clone(), - count: 0, - }; - let started = match ctx .spawn_process_wire(ExecuteRequest { process_id: process_id.clone(), @@ -349,7 +267,6 @@ impl AcpExtension { closed: false, exit_code: None, pending_preamble: None, - restart: restart_state, }; let mut events = Vec::new(); @@ -496,7 +413,7 @@ impl AcpExtension { config_options = overrides.clone(); } if !config_options.iter().any(is_model_config_option) { - config_options.extend(derive_config_options(&request.agent_type, &session_result)); + config_options.extend(derive_config_options(&session_result)); } Ok(CreateSessionBootstrap { @@ -510,63 +427,20 @@ impl AcpExtension { }) } - async fn apply_prompt_injection( + fn apply_prompt_injection( &self, - ctx: &mut ExtensionContext<'_>, request: &AcpCreateSessionRequest, - args: &mut Vec, env: &mut BTreeMap, - ) -> Result<(), SidecarError> { + ) { let prompt = assemble_system_prompt( request.skip_os_instructions, request.additional_instructions.as_deref(), ); if prompt.is_empty() { - return Ok(()); - } - - match request.agent_type.as_str() { - "pi" | "pi-cli" | "claude" => { - args.push(String::from("--append-system-prompt")); - args.push(prompt); - } - "codex" => { - args.push(String::from("--append-developer-instructions")); - args.push(prompt); - } - "opencode" if !env.contains_key("OPENCODE_CONTEXTPATHS") => { - ctx.guest_filesystem_call_wire(GuestFilesystemCallRequest { - operation: GuestFilesystemOperation::WriteFile, - path: String::from(OPENCODE_SYSTEM_PROMPT_PATH), - destination_path: None, - target: None, - content: Some(prompt), - encoding: None, - recursive: false, - max_depth: None, - mode: None, - uid: None, - gid: None, - atime_ms: None, - mtime_ms: None, - len: None, - offset: None, - }) - .await?; - let mut context_paths = OPENCODE_DEFAULT_CONTEXT_PATHS - .iter() - .map(|path| path.to_string()) - .collect::>(); - context_paths.push(OPENCODE_SYSTEM_PROMPT_PATH.to_string()); - env.insert( - String::from("OPENCODE_CONTEXTPATHS"), - serde_json::to_string(&context_paths).expect("serialize context paths"), - ); - } - _ => {} + env.remove(ACP_APPEND_SYSTEM_PROMPT_ENV); + return; } - - Ok(()) + env.insert(String::from(ACP_APPEND_SYSTEM_PROMPT_ENV), prompt); } async fn get_session_state( @@ -761,33 +635,14 @@ impl AcpExtension { { Ok(exchange) => exchange, Err(error) => { - // Adapter process exit is a teardown signal. Log it, surface it - // as an `AcpAgentExitedEvent`, and attempt a bounded auto-restart - // (respawn + native `session/load`/`session/resume` under the - // same session id). Only a successful restart keeps the record; - // otherwise it is evicted (incl. `stdout_buffer`) rather than - // leaked until a `close_session` that may never come. Other - // (transient) failures keep the session so the armed preamble - // can ride a retried prompt. + // Adapter process exit is terminal for this live route. Evict + // it and surface the typed event/error; never respawn the + // adapter or replay the request implicitly. if is_adapter_gone_error(&error) { let exit_code = adapter_exit_code_from_error(&error); - let (event_frame, outcome, error) = self - .handle_adapter_exit(&mut ctx, &request.session_id, exit_code, error) + let (event_frame, error) = self + .handle_adapter_exit(&ctx, &request.session_id, exit_code, error) .await; - if outcome == ADAPTER_RESTART_OUTCOME_RESTARTED { - // The restarted session is live again; re-arm the - // continuation preamble so a retried prompt still - // carries the transcript pointer. - if let Some(preamble) = pending_preamble { - if let Some(session) = - self.sessions.lock().await.get_mut(&request.session_id) - { - if session.pending_preamble.is_none() { - session.pending_preamble = Some(preamble); - } - } - } - } let mut events = Vec::new(); if let Some(frame) = event_frame { // Best-effort: event delivery must not mask the @@ -958,25 +813,7 @@ impl AcpExtension { 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 - { - return AcpHandlerOutput::response(Err(error)); - } - - // Retain the final launch + handshake parameters for adapter - // auto-restart before they are moved into the spawn request. - let restart_state = AdapterRestartState { - runtime: create_like.runtime.clone(), - entrypoint: resolved.entrypoint.clone(), - args: args.clone(), - env: env.clone(), - cwd: create_like.cwd.clone(), - protocol_version: create_like.protocol_version, - client_capabilities: create_like.client_capabilities.clone(), - count: 0, - }; + self.apply_prompt_injection(&create_like, &mut env); let started = match ctx .spawn_process_wire(ExecuteRequest { @@ -1022,7 +859,6 @@ impl AcpExtension { exit_code: None, // Fallback arms the transcript-continuation preamble for the first prompt. pending_preamble: outcome.pending_preamble, - restart: restart_state, }; let mut events = Vec::new(); @@ -1146,7 +982,6 @@ impl AcpExtension { request.session_id.clone(), &init_result, &load_result, - &request.agent_type, agent_capabilities.as_ref(), stdout, notifications, @@ -1207,7 +1042,6 @@ impl AcpExtension { live_session_id, &init_result, &session_result, - &request.agent_type, agent_capabilities.as_ref(), stdout, notifications, @@ -1225,205 +1059,53 @@ impl AcpExtension { format!("{prefix}-{id}") } - /// Handle an unexpected adapter exit observed while driving `session_id`: - /// record the exit on the session record, log it, attempt a bounded - /// auto-restart, and build the `AcpAgentExitedEvent` describing the - /// outcome. Returns the encoded event frame (when the session record still - /// existed), the outcome string, and the error to surface for the - /// in-flight request — augmented with the restart outcome so callers know - /// whether a retry can succeed. - /// - /// Extension dispatch is serialized by the stdio loop (one ACP request is - /// in flight at a time), so no second request can race the restart window - /// between the lock scopes below. + /// Handle an unexpected adapter exit observed while driving `session_id`. + /// The live route is evicted before the terminal event is emitted so a + /// caller retry cannot accidentally target the dead process. async fn handle_adapter_exit( &self, - ctx: &mut ExtensionContext<'_>, + ctx: &ExtensionContext<'_>, session_id: &str, exit_code: Option, error: SidecarError, ) -> ( Option, - &'static str, SidecarError, ) { - // Snapshot + mark the record under the lock; run the (slow) restart - // handshake outside it. - let Some((agent_type, dead_process_id, restart)) = ({ + let Some(session) = ({ let mut sessions = self.sessions.lock().await; - sessions.get_mut(session_id).map(|session| { - session.closed = true; - session.exit_code = exit_code; - ( - session.agent_type.clone(), - session.process_id.clone(), - session.restart.clone(), - ) - }) + sessions.remove(session_id) }) else { - // Record already gone (e.g. connection cleanup won the race); - // nothing to restart and no session to describe in an event. - return (None, ADAPTER_RESTART_OUTCOME_FAILED, error); + return (None, error); }; tracing::warn!( target: "agentos_sidecar::acp_extension", session_id, - agent_type, - process_id = dead_process_id, + agent_type = session.agent_type, + process_id = session.process_id, exit_code = ?exit_code, - restarts_used = restart.count, - max_restarts = MAX_ADAPTER_RESTARTS, - "ACP adapter process exited unexpectedly", + "ACP adapter process exited unexpectedly; live session route evicted", ); - let mut restart_detail: Option = None; - let (outcome, restart_count) = if restart.count >= MAX_ADAPTER_RESTARTS { - self.sessions.lock().await.remove(session_id); - tracing::warn!( - target: "agentos_sidecar::acp_extension", - session_id, - agent_type, - max_restarts = MAX_ADAPTER_RESTARTS, - "ACP adapter restart budget exhausted (raise MAX_ADAPTER_RESTARTS \ - or investigate the crashing adapter); session evicted", - ); - (ADAPTER_RESTART_OUTCOME_EXHAUSTED, restart.count) - } else { - let attempt = restart.count + 1; - if let Some(session) = self.sessions.lock().await.get_mut(session_id) { - session.restart.count = attempt; - } - match self - .restart_adapter(ctx, session_id, &agent_type, &restart) - .await - { - Ok(()) => { - tracing::warn!( - target: "agentos_sidecar::acp_extension", - session_id, - agent_type, - attempt, - max_restarts = MAX_ADAPTER_RESTARTS, - "ACP adapter auto-restarted; session re-attached natively", - ); - (ADAPTER_RESTART_OUTCOME_RESTARTED, attempt) - } - Err(restart_error) => { - self.sessions.lock().await.remove(session_id); - let outcome = match &restart_error { - AdapterRestartError::Unsupported => ADAPTER_RESTART_OUTCOME_UNSUPPORTED, - AdapterRestartError::Failed(_) => ADAPTER_RESTART_OUTCOME_FAILED, - }; - let detail = restart_error.detail(); - tracing::warn!( - target: "agentos_sidecar::acp_extension", - session_id, - agent_type, - attempt, - outcome, - detail = %detail, - "ACP adapter auto-restart did not recover the session; session evicted", - ); - restart_detail = Some(detail); - (outcome, attempt) - } - } - }; - let frame = encode_event(AcpEvent::AcpAgentExitedEvent(AcpAgentExitedEvent { session_id: session_id.to_string(), - agent_type, - process_id: dead_process_id, + agent_type: session.agent_type, + process_id: session.process_id, exit_code, - restart: outcome.to_string(), - restart_count, - max_restarts: MAX_ADAPTER_RESTARTS, + restart: ADAPTER_RESTART_OUTCOME_NOT_ATTEMPTED.to_string(), + restart_count: 0, + max_restarts: 0, })) .and_then(|payload| ctx.ext_event_wire(payload)) .ok(); - let error = SidecarError::InvalidState(match outcome { - ADAPTER_RESTART_OUTCOME_RESTARTED => format!( - "{error}; ACP adapter was auto-restarted (attempt \ - {restart_count}/{MAX_ADAPTER_RESTARTS}) and the session is live again — \ - retry the request" - ), - ADAPTER_RESTART_OUTCOME_EXHAUSTED => format!( - "{error}; ACP adapter restart budget exhausted \ - ({MAX_ADAPTER_RESTARTS} restarts) — session evicted" - ), - _ => format!( - "{error}; ACP adapter auto-restart {outcome} ({}) — session evicted", - restart_detail.as_deref().unwrap_or("no detail") - ), - }); - (frame, outcome, error) - } - - /// Respawn the adapter with the retained launch parameters and natively - /// re-attach `session_id` (`initialize` + `session/load`/`session/resume`). - /// On success the session record points at the new process and is live - /// again; the caller owns eviction on failure. - async fn restart_adapter( - &self, - ctx: &mut ExtensionContext<'_>, - session_id: &str, - agent_type: &str, - restart: &AdapterRestartState, - ) -> Result<(), AdapterRestartError> { - let process_id = self.allocate_process_id("acp-agent"); - let started = ctx - .spawn_process_wire(ExecuteRequest { - process_id: process_id.clone(), - command: None, - runtime: Some(convert_runtime(restart.runtime.clone())), - entrypoint: Some(restart.entrypoint.clone()), - args: restart.args.clone(), - env: restart.env.clone().into_iter().collect(), - cwd: Some(restart.cwd.clone()), - wasm_permission_tier: None, - }) - .await - .map_err(AdapterRestartError::Failed)?; - - let bootstrap = - match restart_handshake(ctx, session_id, agent_type, restart, &process_id).await { - Ok(bootstrap) => bootstrap, - Err(error) => { - kill_process_best_effort(ctx, &process_id).await; - return Err(error); - } - }; - - if let Err(error) = ctx.bind_process_to_session(session_id, &process_id).await { - kill_process_best_effort(ctx, &process_id).await; - return Err(AdapterRestartError::Failed(error)); - } - - { - let mut sessions = self.sessions.lock().await; - let Some(session) = sessions.get_mut(session_id) else { - // The record vanished mid-restart (close/disconnect); don't - // leak the fresh adapter process. - drop(sessions); - kill_process_best_effort(ctx, &process_id).await; - return Err(AdapterRestartError::Failed(SidecarError::InvalidState( - format!("ACP session {session_id} was removed during adapter restart"), - ))); - }; - session.process_id = process_id; - session.pid = started.pid; - session.closed = false; - session.exit_code = None; - session.modes = bootstrap.modes; - session.config_options = bootstrap.config_options; - session.agent_capabilities = bootstrap.agent_capabilities; - session.agent_info = bootstrap.agent_info; - session.stdout_buffer = bootstrap.stdout_buffer; - session.next_request_id = 3; - } - Ok(()) + ( + frame, + SidecarError::InvalidState(format!( + "{error}; ACP adapter exited and the live session route was evicted; restore explicitly before retrying" + )), + ) } /// Drop every session owned by `connection_id`, returning the adapter process @@ -2603,106 +2285,6 @@ fn adapter_exit_code_from_error(error: &SidecarError) -> Option { tail.split_whitespace().next()?.parse().ok() } -/// Drive the restart handshake against a freshly respawned adapter: -/// `initialize` (re-probing capabilities, which cannot be trusted across a -/// relaunch) followed by the native `session/load`/`session/resume` for -/// `session_id`. There is deliberately no `session/new` fallback tier here: -/// a fallback would hand back a *different* adapter session id, which an -/// in-place restart cannot remap transparently — callers that need the -/// fallback tier go through the actor-level lazy resume instead. Replayed -/// load notifications are dropped: the client already observed this session's -/// history live, so re-forwarding the transcript replay would duplicate it. -async fn restart_handshake( - ctx: &mut ExtensionContext<'_>, - session_id: &str, - agent_type: &str, - restart: &AdapterRestartState, - process_id: &str, -) -> Result { - let mut stdout = String::new(); - let client_capabilities = parse_json_text(&restart.client_capabilities, "clientCapabilities") - .map_err(AdapterRestartError::Failed)?; - - let initialize = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": restart.protocol_version, - "clientCapabilities": client_capabilities, - }, - }); - let initialize_response = send_json_rpc_request( - ctx, - process_id, - agent_type, - initialize, - 1, - INITIALIZE_TIMEOUT, - &mut stdout, - None, - ) - .await - .map_err(AdapterRestartError::Failed)?; - let init_result = response_result(initialize_response.response, "ACP initialize") - .map_err(AdapterRestartError::Failed)?; - validate_initialize_result(&init_result, restart.protocol_version) - .map_err(AdapterRestartError::Failed)?; - let agent_capabilities = init_result.get("agentCapabilities").cloned(); - - let Some(native_resume_method) = native_resume_method(agent_capabilities.as_ref()) else { - return Err(AdapterRestartError::Unsupported); - }; - let load = json!({ - "jsonrpc": "2.0", - "id": 2, - "method": native_resume_method, - "params": { - "sessionId": session_id, - "cwd": restart.cwd, - "mcpServers": [], - }, - }); - let load_response = send_json_rpc_request( - ctx, - process_id, - agent_type, - load, - 2, - SESSION_NEW_TIMEOUT, - &mut stdout, - None, - ) - .await - .map_err(AdapterRestartError::Failed)?; - let replayed_notifications = - initialize_response.notifications.len() + load_response.notifications.len(); - if replayed_notifications > 0 { - tracing::debug!( - target: "agentos_sidecar::acp_extension", - session_id, - replayed_notifications, - "dropped adapter-restart replay notifications", - ); - } - let load_result = response_result( - load_response.response, - &format!("ACP {native_resume_method}"), - ) - .map_err(AdapterRestartError::Failed)?; - - build_resume_bootstrap( - session_id.to_string(), - &init_result, - &load_result, - agent_type, - agent_capabilities.as_ref(), - stdout, - Vec::new(), - ) - .map_err(AdapterRestartError::Failed) -} - fn parse_json_text(text: &str, label: &str) -> Result { serde_json::from_str(text) .map_err(|error| SidecarError::InvalidState(format!("invalid {label} JSON: {error}"))) @@ -2769,7 +2351,7 @@ fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static s } fn trace_acp_response(method: &str, response: &Value) { - // Test-only diagnostics for compatibility regressions: the OpenCode resume + // Test-only diagnostics for compatibility regressions: the resume // test captures the raw native-resume response before normalization so we // notice upstream error-shape changes. The env var is sidecar-process // trusted input, not guest-controlled runtime surface. @@ -2792,8 +2374,8 @@ fn trace_acp_response(method: &str, response: &Value) { /// Normalize adapter-specific "no such session" errors from `session/load` into /// the shared `unknown_session` discriminator used by the resume state machine. /// -/// OpenCode currently reports a missing session as JSON-RPC `-32603` with -/// `error.data.details == "NotFoundError"`: its ACP server converts thrown +/// Some adapters report a missing session as JSON-RPC `-32603` with +/// `error.data.details == "NotFoundError"`: the ACP server converts thrown /// non-`RequestError` exceptions into `internalError({ details: error.message })`, /// and `Session.get` throws a `NotFoundError` whose message is the class name. /// Convert exactly that shape into `error.data.kind = "unknown_session"` before @@ -2844,7 +2426,6 @@ fn build_resume_bootstrap( session_id: String, init_result: &Map, session_result: &Map, - agent_type: &str, agent_capabilities: Option<&Value>, stdout_buffer: String, notifications: Vec, @@ -2861,7 +2442,7 @@ fn build_resume_bootstrap( config_options = overrides.clone(); } if !config_options.iter().any(is_model_config_option) { - config_options.extend(derive_config_options(agent_type, session_result)); + config_options.extend(derive_config_options(session_result)); } Ok(CreateSessionBootstrap { @@ -2958,7 +2539,7 @@ fn is_model_config_option(value: &Value) -> bool { }) } -fn derive_config_options(agent_type: &str, session_result: &Map) -> Vec { +fn derive_config_options(session_result: &Map) -> Vec { let Some(models) = session_result.get("models").and_then(Value::as_object) else { return Vec::new(); }; @@ -2999,10 +2580,7 @@ fn derive_config_options(agent_type: &str, session_result: &Map) ), (String::from("label"), Value::String(String::from("Model"))), (String::from("allowedValues"), Value::Array(allowed_values)), - ( - String::from("readOnly"), - Value::Bool(agent_type == "opencode"), - ), + (String::from("readOnly"), Value::Bool(false)), ]); if let Some(current_model_id) = current_model_id { option.insert( @@ -3010,15 +2588,6 @@ fn derive_config_options(agent_type: &str, session_result: &Map) Value::String(current_model_id), ); } - if agent_type == "opencode" { - option.insert( - String::from("description"), - Value::String(String::from( - "Available models reported by OpenCode. Model switching must be configured before createSession() because ACP session/set_config_option is not implemented.", - )), - ); - } - vec![Value::Object(option)] } @@ -3105,16 +2674,18 @@ mod tests { } #[test] - fn unknown_session_normalization_pins_opencode_shape() { - let mut opencode = serde_json::json!({ + fn unknown_session_normalization_pins_known_adapter_shape() { + let mut adapter_response = serde_json::json!({ "error": { "code": -32603, "message": "Internal error", "data": { "details": "NotFoundError" } } }); - normalize_unknown_session_error(&mut opencode); + normalize_unknown_session_error(&mut adapter_response); assert_eq!( - opencode.pointer("/error/data/kind").and_then(Value::as_str), + adapter_response + .pointer("/error/data/kind") + .and_then(Value::as_str), Some("unknown_session") ); - assert!(is_unknown_session_error(&opencode)); + assert!(is_unknown_session_error(&adapter_response)); let mut malformed = serde_json::json!({ "error": { "code": -32602, "message": "Invalid params", @@ -3384,16 +2955,6 @@ mod tests { closed: false, exit_code: None, pending_preamble: None, - restart: AdapterRestartState { - runtime: AcpRuntimeKind::JavaScript, - entrypoint: String::from("/adapter.mjs"), - args: Vec::new(), - env: BTreeMap::new(), - cwd: String::from("/"), - protocol_version: 1, - client_capabilities: String::from("{}"), - count: 0, - }, } } diff --git a/crates/agentos-sidecar/tests/acp_adapter_restart.rs b/crates/agentos-sidecar/tests/acp_adapter_restart.rs deleted file mode 100644 index 4d3b14ab22..0000000000 --- a/crates/agentos-sidecar/tests/acp_adapter_restart.rs +++ /dev/null @@ -1,768 +0,0 @@ -//! Coverage for adapter-crash observability + bounded auto-restart. -//! -//! When the ACP adapter process exits without `close_session` the extension -//! must (a) log the exit, (b) emit an `AcpAgentExitedEvent` describing the -//! sidecar's auto-restart outcome, and (c) attempt the restart itself: -//! respawn the adapter with the retained launch parameters and natively -//! re-attach the session via `initialize` + `session/load`. Only a successful -//! restart keeps the session record; otherwise it is evicted exactly like the -//! pre-restart behavior. -//! -//! One top-level test drives both outcomes through the public Ext RPC surface -//! (multiple libtest cases per V8-backed binary still trip teardown crashes): -//! -//! 1. A `loadSession`-capable adapter crashes on its first `session/prompt` -//! (exit 7). The prompt dispatch fails with the exit diagnostic + restart -//! notice, the dispatch events carry `AcpAgentExitedEvent { restart: -//! "restarted", exit_code: Some(7) }`, and a retried prompt against the -//! SAME session id succeeds against the respawned adapter. -//! 2. An adapter without any native resume capability crashes the same way. -//! The event reports `restart: "unsupported"` and the session record is -//! evicted (a follow-up prompt fails with unknown-session). -//! -//! This is bounded ("still test the expensive safeguards"): every crash is an -//! immediate `process.exit`, and the restart handshake ends the exchange, so -//! nothing waits on a watchdog. - -#[path = "support/bridge.rs"] -mod bridge_support; - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; - -use agentos_native_sidecar::wire::{ - AuthenticateRequest, ConfigureVmRequest, ConnectionOwnership, CreateVmRequest, EventFrame, - EventPayload, ExtEnvelope, GuestRuntimeKind, OpenSessionRequest, OwnershipScope, - PackageDescriptor, RequestFrame, RequestPayload, ResponsePayload, SessionOwnership, - SidecarPlacement, SidecarPlacementShared, VmOwnership, -}; -use agentos_native_sidecar::{NativeSidecar, NativeSidecarConfig}; -use agentos_protocol::generated::v1::{ - AcpAgentExitedEvent, AcpCreateSessionRequest, AcpErrorResponse, AcpEvent, AcpRequest, - AcpResponse, AcpRuntimeKind, AcpSessionRequest, -}; -use agentos_protocol::{ACP_EXTENSION_NAMESPACE, PROTOCOL_VERSION as ACP_PROTOCOL_VERSION}; -use agentos_vm_config as vm_config; -use bridge_support::RecordingBridge; - -#[test] -fn adapter_crash_restarts_or_evicts_and_emits_exit_event() { - assert_node_available(); - let mut sidecar = new_sidecar("adapter-restart"); - - let connection_id = authenticate(&mut sidecar); - let session_id = open_session(&mut sidecar, &connection_id); - let cwd = temp_dir("adapter-restart-cwd"); - let restartable = cwd.join("restartable-adapter.mjs"); - fs::write(&restartable, restartable_adapter_script()).expect("write restartable adapter"); - let unsupported = cwd.join("unsupported-adapter.mjs"); - fs::write(&unsupported, unsupported_adapter_script()).expect("write unsupported adapter"); - let idle = cwd.join("idle-crash-adapter.mjs"); - fs::write(&idle, idle_crash_adapter_script()).expect("write idle-crash adapter"); - let vm_id = create_vm(&mut sidecar, &connection_id, &session_id, &cwd); - - // ------------------------------------------------------------------ - // Scenario 1: loadSession-capable adapter → crash is auto-restarted. - // ------------------------------------------------------------------ - let created = dispatch_acp( - &mut sidecar, - 4, - &connection_id, - &session_id, - &vm_id, - create_session_request(&restartable, &cwd), - ) - .0; - let AcpResponse::AcpSessionCreatedResponse(created) = created else { - panic!("unexpected create response: {created:?}"); - }; - assert_eq!(created.session_id, "restartable-session"); - - // First prompt: the adapter exits(7) without responding. The dispatch must - // surface the exit AND report that the session was auto-restarted. - let (response, events) = dispatch_acp( - &mut sidecar, - 5, - &connection_id, - &session_id, - &vm_id, - prompt_request("restartable-session", "hello"), - ); - let AcpResponse::AcpErrorResponse(AcpErrorResponse { code, message }) = response else { - panic!("expected the crashed prompt to fail, got: {response:?}"); - }; - assert_eq!(code, "invalid_state"); - assert!( - message.contains("exited with code 7"), - "expected exit diagnostic in: {message}" - ); - assert!( - message.contains("auto-restarted") && message.contains("retry the request"), - "expected restart notice in: {message}" - ); - let exit_event = decode_single_agent_exited_event(&events); - assert_eq!(exit_event.session_id, "restartable-session"); - assert_eq!(exit_event.agent_type, "restartable"); - assert_eq!(exit_event.exit_code, Some(7)); - assert_eq!(exit_event.restart, "restarted"); - assert_eq!(exit_event.restart_count, 1); - assert_eq!(exit_event.max_restarts, 3); - - // Retried prompt: the respawned adapter (which answered `session/load` for - // the same session id) must serve it. - let (response, _) = dispatch_acp( - &mut sidecar, - 6, - &connection_id, - &session_id, - &vm_id, - prompt_request("restartable-session", "hello again"), - ); - let AcpResponse::AcpSessionRpcResponse(prompt) = response else { - panic!("expected the retried prompt to succeed, got: {response:?}"); - }; - assert_eq!(prompt.session_id, "restartable-session"); - let prompt_response: serde_json::Value = - serde_json::from_str(&prompt.response).expect("prompt response json"); - assert_eq!(prompt_response["result"]["echo"], "hello again"); - - // ------------------------------------------------------------------ - // Scenario 2: no native resume capability → crash evicts the session. - // ------------------------------------------------------------------ - let created = dispatch_acp( - &mut sidecar, - 7, - &connection_id, - &session_id, - &vm_id, - create_session_request(&unsupported, &cwd), - ) - .0; - let AcpResponse::AcpSessionCreatedResponse(created) = created else { - panic!("unexpected create response: {created:?}"); - }; - assert_eq!(created.session_id, "unsupported-session"); - - let (response, events) = dispatch_acp( - &mut sidecar, - 8, - &connection_id, - &session_id, - &vm_id, - prompt_request("unsupported-session", "hello"), - ); - let AcpResponse::AcpErrorResponse(AcpErrorResponse { code, message }) = response else { - panic!("expected the crashed prompt to fail, got: {response:?}"); - }; - assert_eq!(code, "invalid_state"); - assert!( - message.contains("exited with code 3"), - "expected exit diagnostic in: {message}" - ); - assert!( - message.contains("auto-restart unsupported") && message.contains("session evicted"), - "expected unsupported-restart notice in: {message}" - ); - let exit_event = decode_single_agent_exited_event(&events); - assert_eq!(exit_event.session_id, "unsupported-session"); - assert_eq!(exit_event.exit_code, Some(3)); - assert_eq!(exit_event.restart, "unsupported"); - assert_eq!(exit_event.restart_count, 1); - - // The record is gone: a follow-up prompt fails with unknown-session. - let (response, _) = dispatch_acp( - &mut sidecar, - 9, - &connection_id, - &session_id, - &vm_id, - prompt_request("unsupported-session", "anyone home?"), - ); - let AcpResponse::AcpErrorResponse(AcpErrorResponse { message, .. }) = response else { - panic!("expected the post-eviction prompt to fail, got: {response:?}"); - }; - assert!( - message.contains("unknown ACP session"), - "expected unknown-session error after eviction, got: {message}" - ); - - // ------------------------------------------------------------------ - // Scenario 3: idle crash (between turns) is detected LAZILY on the - // next request, still emits the exit event, and still auto-restarts. - // ------------------------------------------------------------------ - let created = dispatch_acp( - &mut sidecar, - 10, - &connection_id, - &session_id, - &vm_id, - create_session_request(&idle, &cwd), - ) - .0; - let AcpResponse::AcpSessionCreatedResponse(created) = created else { - panic!("unexpected create response: {created:?}"); - }; - assert_eq!(created.session_id, "idle-session"); - - // First prompt succeeds normally; the adapter exits AFTER responding, so - // the crash happens while the session is idle and nothing observes it yet. - let (response, _) = dispatch_acp( - &mut sidecar, - 11, - &connection_id, - &session_id, - &vm_id, - prompt_request("idle-session", "first"), - ); - let AcpResponse::AcpSessionRpcResponse(prompt) = response else { - panic!("expected the first prompt to succeed, got: {response:?}"); - }; - let prompt_response: serde_json::Value = - serde_json::from_str(&prompt.response).expect("prompt response json"); - assert_eq!(prompt_response["result"]["echo"], "first"); - - // Give the adapter process time to actually die while idle. - std::thread::sleep(std::time::Duration::from_millis(1500)); - - // The next prompt is the lazy detection point: it must fail with the - // adapter-gone diagnostic, emit the exit event, and auto-restart. - let (response, events) = dispatch_acp( - &mut sidecar, - 12, - &connection_id, - &session_id, - &vm_id, - prompt_request("idle-session", "second"), - ); - let AcpResponse::AcpErrorResponse(AcpErrorResponse { code, message }) = response else { - panic!("expected the post-idle-crash prompt to fail, got: {response:?}"); - }; - assert_eq!(code, "invalid_state"); - // Diagnostic: shows which lazy-observation path fired on this run - // (failed stdin write "has no active process" vs queued exit event). - eprintln!("idle-crash lazy detection error: {message}"); - assert!( - message.contains("auto-restarted") && message.contains("retry the request"), - "expected restart notice in: {message}" - ); - let exit_event = decode_single_agent_exited_event(&events); - assert_eq!(exit_event.session_id, "idle-session"); - assert_eq!(exit_event.restart, "restarted"); - assert_eq!(exit_event.restart_count, 1); - // Which observation path fires depends on whether the reap completed - // before the request's stdin write: a failed write ("has no active - // process") observes NO exit code, while the in-pump ProcessExitedEvent - // observes the real code (0). Both are the lazy-detection contract. - assert!( - exit_event.exit_code.is_none() || exit_event.exit_code == Some(0), - "unexpected exit code for idle crash: {:?}", - exit_event.exit_code - ); - - // The restarted adapter serves the same session id on the next turn. - let (response, _) = dispatch_acp( - &mut sidecar, - 13, - &connection_id, - &session_id, - &vm_id, - prompt_request("idle-session", "third"), - ); - let AcpResponse::AcpSessionRpcResponse(prompt) = response else { - panic!("expected the post-restart prompt to succeed, got: {response:?}"); - }; - assert_eq!(prompt.session_id, "idle-session"); - let prompt_response: serde_json::Value = - serde_json::from_str(&prompt.response).expect("prompt response json"); - assert_eq!(prompt_response["result"]["echo"], "third"); -} - -/// Adapter that advertises `loadSession` and exits(0) AFTER successfully -/// answering its first post-`session/new` prompt — an idle crash between -/// turns. After a `session/load` re-attach it answers prompts normally. -/// The response is flushed before exiting so the crashing turn itself -/// succeeds and the exit is only observable lazily, on the next request. -fn idle_crash_adapter_script() -> &'static str { - r#" -import readline from "node:readline"; - -const lines = readline.createInterface({ input: process.stdin }); -let loaded = false; - -for await (const line of lines) { - if (!line.trim()) continue; - const message = JSON.parse(line); - if (message.method === "initialize") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - protocolVersion: message.params.protocolVersion, - agentCapabilities: { loadSession: true }, - agentInfo: { name: "idle-crash-acp-adapter" }, - configOptions: [] - } - })); - } else if (message.method === "session/new") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - sessionId: "idle-session", - modes: { currentModeId: "default", availableModes: [] } - } - })); - } else if (message.method === "session/load") { - loaded = true; - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - modes: { currentModeId: "default", availableModes: [] } - } - })); - } else if (message.method === "session/prompt") { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - stopReason: "end_turn", - echo: message.params.prompt?.[0]?.text ?? null - } - }); - if (!loaded) { - // Flush the response, then die while the session is idle. - process.stdout.write(response + "\n", () => process.exit(0)); - } else { - console.log(response); - } - } else { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: `unknown method ${message.method}` } - })); - } -} -"# -} - -/// Adapter that advertises `loadSession`, crashes (exit 7) on the first -/// `session/prompt` after `session/new`, but answers prompts normally once the -/// session was re-attached via `session/load`. Process-local state only, so -/// the exact same script serves both the original and the respawned launch. -fn restartable_adapter_script() -> &'static str { - r#" -import readline from "node:readline"; - -const lines = readline.createInterface({ input: process.stdin }); -let loaded = false; - -for await (const line of lines) { - if (!line.trim()) continue; - const message = JSON.parse(line); - if (message.method === "initialize") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - protocolVersion: message.params.protocolVersion, - agentCapabilities: { loadSession: true }, - agentInfo: { name: "restartable-acp-adapter" }, - configOptions: [] - } - })); - } else if (message.method === "session/new") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - sessionId: "restartable-session", - modes: { currentModeId: "default", availableModes: [] } - } - })); - } else if (message.method === "session/load") { - loaded = true; - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - modes: { currentModeId: "default", availableModes: [] } - } - })); - } else if (message.method === "session/prompt") { - if (!loaded) { - process.stderr.write("fatal: adapter crashed handling session/prompt\n"); - process.exit(7); - } - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - stopReason: "end_turn", - echo: message.params.prompt?.[0]?.text ?? null - } - })); - } else { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: `unknown method ${message.method}` } - })); - } -} -"# -} - -/// Adapter with NO native resume capability that crashes (exit 3) on -/// `session/prompt`: the auto-restart must classify it `unsupported` and evict. -fn unsupported_adapter_script() -> &'static str { - r#" -import readline from "node:readline"; - -const lines = readline.createInterface({ input: process.stdin }); - -for await (const line of lines) { - if (!line.trim()) continue; - const message = JSON.parse(line); - if (message.method === "initialize") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - protocolVersion: message.params.protocolVersion, - agentInfo: { name: "unsupported-acp-adapter" }, - configOptions: [] - } - })); - } else if (message.method === "session/new") { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - result: { - sessionId: "unsupported-session", - modes: { currentModeId: "default", availableModes: [] } - } - })); - } else if (message.method === "session/prompt") { - process.stderr.write("fatal: adapter crashed handling session/prompt\n"); - process.exit(3); - } else { - console.log(JSON.stringify({ - jsonrpc: "2.0", - id: message.id, - error: { code: -32601, message: `unknown method ${message.method}` } - })); - } -} -"# -} - -fn create_session_request(adapter: &Path, cwd: &Path) -> AcpRequest { - AcpRequest::AcpCreateSessionRequest(AcpCreateSessionRequest { - agent_type: agent_type_for_adapter(adapter), - runtime: AcpRuntimeKind::JavaScript, - cwd: cwd.to_string_lossy().into_owned(), - args: Vec::new(), - env: HashMap::new(), - protocol_version: i32::from(ACP_PROTOCOL_VERSION), - client_capabilities: String::from(r#"{"fs":{}}"#), - mcp_servers: String::from(r#"{"servers":[]}"#), - skip_os_instructions: true, - additional_instructions: None, - }) -} - -fn prompt_request(session_id: &str, text: &str) -> AcpRequest { - AcpRequest::AcpSessionRequest(AcpSessionRequest { - session_id: String::from(session_id), - method: String::from("session/prompt"), - params: Some(format!( - r#"{{"prompt":[{{"type":"text","text":"{text}"}}]}}"# - )), - }) -} - -/// Decode the single `AcpAgentExitedEvent` in a dispatch's event batch (which -/// may also carry `AcpAgentStderrEvent`s from the crashing adapter). -fn decode_single_agent_exited_event(events: &[EventFrame]) -> AcpAgentExitedEvent { - let mut exited = Vec::new(); - for frame in events { - let EventPayload::ExtEnvelope(envelope) = &frame.payload else { - continue; - }; - if envelope.namespace != ACP_EXTENSION_NAMESPACE { - continue; - } - let event: AcpEvent = serde_bare::from_slice(&envelope.payload).expect("decode ACP event"); - if let AcpEvent::AcpAgentExitedEvent(event) = event { - exited.push(event); - } - } - assert_eq!( - exited.len(), - 1, - "expected exactly one AcpAgentExitedEvent, got {exited:?}" - ); - exited.remove(0) -} - -fn dispatch_acp( - sidecar: &mut NativeSidecar, - request_id: i64, - connection_id: &str, - session_id: &str, - vm_id: &str, - request: AcpRequest, -) -> (AcpResponse, Vec) { - let payload = serde_bare::to_vec(&request).expect("encode ACP request"); - let result = sidecar - .dispatch_wire_blocking(RequestFrame { - schema: agentos_native_sidecar::wire::protocol_schema(), - request_id, - ownership: OwnershipScope::VmOwnership(VmOwnership { - connection_id: connection_id.to_owned(), - session_id: session_id.to_owned(), - vm_id: vm_id.to_owned(), - }), - payload: RequestPayload::ExtEnvelope(ExtEnvelope { - namespace: String::from(ACP_EXTENSION_NAMESPACE), - payload, - }), - }) - .expect("dispatch ACP extension request"); - - match result.response.payload { - ResponsePayload::ExtEnvelope(envelope) => { - assert_eq!(envelope.namespace, ACP_EXTENSION_NAMESPACE); - ( - serde_bare::from_slice(&envelope.payload).expect("decode ACP response"), - result.events, - ) - } - ResponsePayload::RejectedResponse(rejected) => panic!( - "ACP dispatch was rejected at the wire layer instead of returning an \ - ACP error response: code={} message={}", - rejected.code, rejected.message - ), - other => panic!("unexpected sidecar response: {other:?}"), - } -} - -fn assert_node_available() { - let output = Command::new("node") - .arg("--version") - .output() - .expect("spawn node --version"); - assert!(output.status.success(), "node must be available"); -} - -fn new_sidecar(name: &str) -> NativeSidecar { - NativeSidecar::with_config_and_extensions( - RecordingBridge::default(), - NativeSidecarConfig { - sidecar_id: format!("sidecar-{name}"), - compile_cache_root: Some(temp_dir(name).join("cache")), - ..NativeSidecarConfig::default() - }, - agentos_sidecar_wrapper::extensions(), - ) - .expect("create native sidecar") -} - -fn authenticate(sidecar: &mut NativeSidecar) -> String { - let result = sidecar - .dispatch_wire_blocking(RequestFrame { - schema: agentos_native_sidecar::wire::protocol_schema(), - request_id: 1, - ownership: OwnershipScope::ConnectionOwnership(ConnectionOwnership { - connection_id: String::from("client"), - }), - payload: RequestPayload::AuthenticateRequest(AuthenticateRequest { - client_name: String::from("acp-extension-adapter-restart"), - auth_token: String::new(), - protocol_version: agentos_native_sidecar::wire::PROTOCOL_VERSION, - bridge_version: agentos_bridge::bridge_contract().version, - }), - }) - .expect("authenticate"); - match result.response.payload { - ResponsePayload::AuthenticatedResponse(response) => response.connection_id, - other => panic!("unexpected auth response: {other:?}"), - } -} - -fn open_session(sidecar: &mut NativeSidecar, connection_id: &str) -> String { - let result = sidecar - .dispatch_wire_blocking(RequestFrame { - schema: agentos_native_sidecar::wire::protocol_schema(), - request_id: 2, - ownership: OwnershipScope::ConnectionOwnership(ConnectionOwnership { - connection_id: connection_id.to_owned(), - }), - payload: RequestPayload::OpenSessionRequest(OpenSessionRequest { - placement: SidecarPlacement::SidecarPlacementShared(SidecarPlacementShared { - pool: None, - }), - metadata: HashMap::new(), - }), - }) - .expect("open session"); - match result.response.payload { - ResponsePayload::SessionOpenedResponse(response) => response.session_id, - other => panic!("unexpected session response: {other:?}"), - } -} - -fn create_vm( - sidecar: &mut NativeSidecar, - connection_id: &str, - session_id: &str, - cwd: &Path, -) -> String { - let result = sidecar - .dispatch_wire_blocking(RequestFrame { - schema: agentos_native_sidecar::wire::protocol_schema(), - request_id: 3, - ownership: OwnershipScope::SessionOwnership(SessionOwnership { - connection_id: connection_id.to_owned(), - session_id: session_id.to_owned(), - }), - payload: RequestPayload::CreateVmRequest(CreateVmRequest { - runtime: GuestRuntimeKind::JavaScript, - config: serde_json::to_string(&vm_config::CreateVmConfig { - cwd: Some(cwd.to_string_lossy().into_owned()), - permissions: Some(allow_all_permissions()), - ..Default::default() - }) - .expect("serialize create VM config"), - }), - }) - .expect("create VM"); - let vm_id = match result.response.payload { - ResponsePayload::VmCreatedResponse(response) => response.vm_id, - other => panic!("unexpected create VM response: {other:?}"), - }; - configure_mock_agent_packages(sidecar, connection_id, session_id, &vm_id, cwd); - vm_id -} - -fn configure_mock_agent_packages( - sidecar: &mut NativeSidecar, - connection_id: &str, - session_id: &str, - vm_id: &str, - cwd: &Path, -) { - let adapters: Vec = fs::read_dir(cwd) - .expect("read adapter cwd") - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with("-adapter.mjs")) - }) - .collect(); - if adapters.is_empty() { - return; - } - let package_root = cwd.join("packages"); - fs::create_dir_all(&package_root).expect("create mock package root"); - let mut packages = Vec::new(); - for adapter in adapters { - let agent_type = agent_type_for_adapter(&adapter); - let script = fs::read_to_string(&adapter).expect("read restart adapter"); - let package_dir = package_root.join(&agent_type); - let bin_dir = package_dir.join("bin"); - fs::create_dir_all(&bin_dir).expect("create mock agent bin dir"); - let manifest = serde_json::json!({ - "name": agent_type, - "version": "0.0.0", - "agent": { "acpEntrypoint": agent_type }, - }) - .to_string(); - fs::write(package_dir.join("agentos-package.json"), manifest) - .expect("write mock agent manifest"); - fs::write(bin_dir.join(&agent_type), script).expect("write mock agent command"); - packages.push(PackageDescriptor { - path: package_dir.to_string_lossy().into_owned(), - }); - } - let result = sidecar - .dispatch_wire_blocking(RequestFrame { - schema: agentos_native_sidecar::wire::protocol_schema(), - request_id: 30, - ownership: OwnershipScope::VmOwnership(VmOwnership { - connection_id: connection_id.to_owned(), - session_id: session_id.to_owned(), - vm_id: vm_id.to_owned(), - }), - payload: RequestPayload::ConfigureVmRequest(ConfigureVmRequest { - mounts: Vec::new(), - software: Vec::new(), - permissions: None, - module_access_cwd: None, - instructions: Vec::new(), - projected_modules: Vec::new(), - command_permissions: HashMap::new(), - loopback_exempt_ports: Vec::new(), - packages, - packages_mount_at: String::from("/opt/agentos"), - bootstrap_commands: Vec::new(), - binding_shim_commands: Vec::new(), - }), - }) - .expect("configure restart ACP packages"); - assert!(matches!( - result.response.payload, - ResponsePayload::VmConfiguredResponse(_) - )); -} - -fn agent_type_for_adapter(adapter: &Path) -> String { - adapter - .file_name() - .and_then(|name| name.to_str()) - .and_then(|name| name.strip_suffix("-adapter.mjs")) - .unwrap_or("pi") - .to_owned() -} - -fn allow_all_permissions() -> vm_config::PermissionsPolicy { - vm_config::PermissionsPolicy { - fs: Some(vm_config::FsPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - network: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - child_process: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - process: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - env: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - binding: Some(vm_config::PatternPermissionScope::Mode( - vm_config::PermissionMode::Allow, - )), - } -} - -fn temp_dir(name: &str) -> PathBuf { - let root = std::env::temp_dir().join(format!( - "agentos-sidecar-{name}-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time before unix epoch") - .as_nanos() - )); - fs::create_dir_all(&root).expect("create temp dir"); - root -} diff --git a/crates/agentos-sidecar/tests/acp_adapter_stderr.rs b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs index 2e76957af4..330026d05f 100644 --- a/crates/agentos-sidecar/tests/acp_adapter_stderr.rs +++ b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs @@ -117,6 +117,36 @@ fn adapter_stderr_and_exit_surface_to_caller() { message.contains("exited with code 1"), "expected adapter-exit diagnostic to surface to caller, got: {message}" ); + assert!( + message.contains("live session route was evicted") + && message.contains("restore explicitly"), + "adapter exits must be terminal and must not trigger an implicit restart: {message}" + ); + + let retry = dispatch_acp( + &mut sidecar, + 7, + &connection_id, + &session_id, + &vm_id, + AcpRequest::AcpSessionRequest(AcpSessionRequest { + session_id: String::from("adapter-session"), + method: String::from("session/prompt"), + params: Some(String::from( + r#"{"prompt":[{"type":"text","text":"do not replay"}]}"#, + )), + }), + ); + let AcpResponse::AcpErrorResponse(retry) = retry else { + panic!("terminal adapter exit must evict the route, got: {retry:?}"); + }; + assert!( + retry + .message + .contains("unknown ACP session adapter-session"), + "a retry must require explicit restoration instead of replay: {}", + retry.message + ); } /// Adapter that handshakes correctly, then on `session/prompt` writes a diff --git a/crates/agentos-sidecar/tests/acp_extension.rs b/crates/agentos-sidecar/tests/acp_extension.rs index ef03290d15..946d62155f 100644 --- a/crates/agentos-sidecar/tests/acp_extension.rs +++ b/crates/agentos-sidecar/tests/acp_extension.rs @@ -127,11 +127,9 @@ fn acp_extension_creates_reports_and_closes_session_over_ext() { .expect("agent info should be present"), ) .expect("agent info json"); - let args = agent_info["args"].as_array().expect("args array"); - assert_eq!(args[0], "--append-system-prompt"); - assert!(args[1] + assert!(agent_info["systemPrompt"] .as_str() - .expect("prompt arg") + .expect("system prompt env") .contains("extra guidance")); assert!(created .config_options @@ -888,7 +886,7 @@ for await (const line of lines) { protocolVersion: message.params.protocolVersion, agentInfo: { name: "mock-acp-adapter", - args: process.argv.slice(2), + systemPrompt: process.env.ACP_APPEND_SYSTEM_PROMPT || null, }, configOptions: [] } diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 60ba082a35..889b8c5941 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -83,14 +83,11 @@ pub type AgentExitSubscription = (AgentExitStream, Subscription); /// An unexpected ACP adapter process exit — a crash from the host's /// perspective (any spontaneous exit without `close_session`, including exit -/// code 0) — plus the sidecar's bounded auto-restart outcome. Mirrors the wire +/// code 0). Mirrors the wire /// `AcpAgentExitedEvent` and the TS `AgentExitEvent`. /// -/// `restart` is one of `"restarted"` (adapter respawned and the session -/// natively re-attached under the same id; still usable), `"unsupported"` -/// (adapter lacks `loadSession`/`resume`; session evicted), `"failed"` -/// (respawn/re-attach errored; evicted), or `"exhausted"` (restart budget -/// spent; evicted). +/// `restart` is always `"not_attempted"`: AgentOS evicts the live route and +/// never respawns the adapter or replays the interrupted request implicitly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentExitEvent { #[serde(rename = "sessionId")] @@ -673,12 +670,8 @@ fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) { /// Synthesize the unsupported-config JSON-RPC error response (`-32601`). Mirrors /// `_unsupportedConfigResponse`. -fn unsupported_config_response(agent_type: &str, category: &str) -> JsonRpcResponse { - let message = if agent_type == "opencode" && category == "model" { - "OpenCode reports available models, but model switching must be configured before createSession() because ACP session/set_config_option is not implemented.".to_string() - } else { - format!("The {category} config option is read-only for {agent_type} sessions.") - }; +fn unsupported_config_response(_agent_type: &str, category: &str) -> JsonRpcResponse { + let message = format!("The {category} config option is read-only for this session."); JsonRpcResponse { jsonrpc: "2.0".to_string(), id: Some(JsonRpcId::Null), @@ -1838,9 +1831,9 @@ impl AgentOs { } /// Subscribe to unexpected adapter process exits (crashes) for a session, - /// including the sidecar's bounded auto-restart outcome. Only events - /// emitted after subscription are delivered; only `restart == "restarted"` - /// leaves the session usable. Mirrors the TS `onAgentExit` option. + /// after the sidecar has evicted the terminal live route. Only events + /// emitted after subscription are delivered. Mirrors the TS `onAgentExit` + /// option. pub fn on_agent_exit( &self, session_id: &str, diff --git a/crates/client/tests/os_instructions_e2e.rs b/crates/client/tests/os_instructions_e2e.rs index 9c3aa66b0f..5777b208b1 100644 --- a/crates/client/tests/os_instructions_e2e.rs +++ b/crates/client/tests/os_instructions_e2e.rs @@ -2,10 +2,9 @@ //! //! The base prompt is no longer baked into a guest file (`/etc/agentos/instructions.md` is gone); //! the Agent OS client passes create-time additions and generated tool docs to the wrapper sidecar, -//! which assembles them with the base prompt and injects the result into the launched adapter's argv -//! (`--append-system-prompt` for `pi`). This test resolves a tiny mock ACP adapter through the real -//! module-access path, launches a `pi` session, and asserts the adapter actually observed the -//! injected prompt in `process.argv`. +//! which assembles them with the base prompt and passes the result through the adapter-neutral +//! launch environment. This test resolves a tiny mock ACP adapter through the real module-access +//! path, launches a `pi` session, and asserts the adapter actually observed the injected prompt. mod common; @@ -21,9 +20,8 @@ use agentos_client::{AgentOs, CreateSessionOptions}; use serde_json::json; use uuid::Uuid; -/// A mock ACP adapter that answers `initialize` / `session/new` and echoes its own `process.argv` -/// (minus `node` + script path) in the initialize `agentInfo.argv` so the test can read it from the -/// session's agent info without depending on guest-to-kernel file sync timing. +/// A mock ACP adapter that answers `initialize` / `session/new` and echoes the generic system-prompt +/// environment value in `agentInfo` so the test can read it without guest filesystem timing. const MOCK_ACP_ADAPTER: &str = r#" let buffer = ""; process.stdin.resume(); @@ -42,7 +40,7 @@ process.stdin.on("data", (chunk) => { case "initialize": result = { protocolVersion: 1, - agentInfo: { name: "mock-acp", version: "1.0.0", argv: process.argv.slice(2) }, + agentInfo: { name: "mock-acp", version: "1.0.0", systemPrompt: process.env.ACP_APPEND_SYSTEM_PROMPT || null }, }; break; case "session/new": @@ -64,7 +62,7 @@ setInterval(() => {}, 1000); const ADDITIONAL_MARKER: &str = "rust-client-extra-instructions"; /// Allow-all permissions so the mock adapter can spawn, read its module-access bin, and write the -/// argv probe file. +/// prompt probe. fn allow_all_permissions() -> Permissions { Permissions { fs: Some(FsPermissions::Mode(PermissionMode::Allow)), @@ -103,22 +101,22 @@ fn write_mock_pi_adapter(module_root: &std::path::Path) -> std::path::PathBuf { package_dir } -async fn launch_pi_session_and_read_argv(options: CreateSessionOptions) -> Vec { - launch_pi_session_with_tools_and_read_argv(options, Vec::new()).await +async fn launch_pi_session_and_read_prompt(options: CreateSessionOptions) -> String { + launch_pi_session_with_tools_and_read_prompt(options, Vec::new()).await } -async fn launch_pi_session_with_tools_and_read_argv( +async fn launch_pi_session_with_tools_and_read_prompt( options: CreateSessionOptions, bindings: Vec, -) -> Vec { +) -> String { let module_access_dir = std::env::temp_dir().join(format!("agentos-client-os-instructions-{}", Uuid::new_v4())); let package_dir = write_mock_pi_adapter(&module_access_dir); - let argv = run_session(&module_access_dir, &package_dir, options, bindings).await; + let prompt = run_session(&module_access_dir, &package_dir, options, bindings).await; std::fs::remove_dir_all(&module_access_dir).ok(); - argv + prompt } async fn run_session( @@ -126,7 +124,7 @@ async fn run_session( package_dir: &Path, options: CreateSessionOptions, bindings: Vec, -) -> Vec { +) -> String { let os = AgentOs::create(AgentOsConfig { mounts: vec![node_modules_mount( module_access_dir @@ -155,27 +153,15 @@ async fn run_session( let agent_info = os .get_session_agent_info(&session.session_id) .expect("mock adapter should report agent info"); - let argv: Vec = serde_json::from_value( - agent_info - .extra - .get("argv") - .cloned() - .expect("mock adapter should echo argv in agentInfo"), - ) - .expect("argv probe is a JSON string array"); + let prompt = agent_info + .extra + .get("systemPrompt") + .and_then(serde_json::Value::as_str) + .expect("mock adapter should echo the system prompt in agentInfo") + .to_string(); os.shutdown().await.expect("shutdown VM"); - argv -} - -fn injected_prompt(argv: &[String]) -> &str { - let idx = argv - .iter() - .position(|arg| arg == "--append-system-prompt") - .unwrap_or_else(|| panic!("argv should contain --append-system-prompt, got {argv:?}")); - argv.get(idx + 1) - .unwrap_or_else(|| panic!("--append-system-prompt should be followed by a value: {argv:?}")) - .as_str() + prompt } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -187,13 +173,12 @@ async fn create_session_injects_assembled_system_prompt() { } common::ensure_sidecar_env(); - let argv = launch_pi_session_and_read_argv(CreateSessionOptions { + let prompt = launch_pi_session_and_read_prompt(CreateSessionOptions { additional_instructions: Some(ADDITIONAL_MARKER.to_string()), ..Default::default() }) .await; - let prompt = injected_prompt(&argv); assert!( prompt.contains("# agentOS"), "base OS instructions are injected: {prompt:?}" @@ -213,7 +198,7 @@ async fn create_session_injects_binding_reference_from_client_config() { } common::ensure_sidecar_env(); - let argv = launch_pi_session_with_tools_and_read_argv( + let prompt = launch_pi_session_with_tools_and_read_prompt( CreateSessionOptions::default(), vec![Bindings { name: "weather".to_string(), @@ -235,7 +220,6 @@ async fn create_session_injects_binding_reference_from_client_config() { ) .await; - let prompt = injected_prompt(&argv); assert!( prompt.contains("## Available Host Bindings"), "client-generated tool reference is injected: {prompt:?}" @@ -255,7 +239,7 @@ async fn create_session_skip_os_instructions_drops_base_but_keeps_additional() { } common::ensure_sidecar_env(); - let argv = launch_pi_session_and_read_argv(CreateSessionOptions { + let prompt = launch_pi_session_and_read_prompt(CreateSessionOptions { skip_os_instructions: true, additional_instructions: Some(ADDITIONAL_MARKER.to_string()), env: BTreeMap::new(), @@ -263,7 +247,6 @@ async fn create_session_skip_os_instructions_drops_base_but_keeps_additional() { }) .await; - let prompt = injected_prompt(&argv); assert!( !prompt.contains("# agentOS"), "skip_os_instructions drops the base prompt: {prompt:?}" diff --git a/crates/native-sidecar-browser/src/lib.rs b/crates/native-sidecar-browser/src/lib.rs index 1541b127cb..4c6fa2c22e 100644 --- a/crates/native-sidecar-browser/src/lib.rs +++ b/crates/native-sidecar-browser/src/lib.rs @@ -1,5 +1,7 @@ #![forbid(unsafe_code)] +// AGENTOS_BROWSER_SUPPORT_DISABLED: retained for reference, but AgentOS is native-only. +/* //! Browser-side sidecar scaffold for the secure-exec runtime migration. mod service; @@ -119,3 +121,4 @@ pub fn scaffold() -> BrowserSidecarScaffold { guest_worker_owner_thread: "main", } } +*/ diff --git a/crates/native-sidecar/tests/architecture_guards.rs b/crates/native-sidecar/tests/architecture_guards.rs index a5ab37142d..d23c3346b3 100644 --- a/crates/native-sidecar/tests/architecture_guards.rs +++ b/crates/native-sidecar/tests/architecture_guards.rs @@ -409,7 +409,6 @@ const PROCESS_ALLOW: &[&str] = &[ const ENV_ALLOW: &[&str] = &[ "crates/sidecar-client/src/transport.rs", "crates/client/src/sidecar.rs", - "crates/agentos-actor-plugin/src/lib.rs", "crates/agentos-sidecar/src/acp_extension.rs", "crates/agentos-sidecar/src/main.rs", "crates/execution/src/host_node.rs", @@ -660,6 +659,85 @@ fn generic_runtime_layers_do_not_depend_on_product_or_acp_layers() { ); } +#[test] +fn shared_acp_runtime_has_no_adapter_name_policy() { + let root = repo_root(); + let source = std::fs::read_to_string(root.join("crates/agentos-sidecar/src/acp_extension.rs")) + .expect("read native ACP extension"); + let production = source.split("#[cfg(test)]").next().unwrap_or(&source); + for adapter_name in [ + "\"claude\"", + "\"codex\"", + "\"opencode\"", + "\"pi\"", + "\"pi-cli\"", + ] { + assert!( + !production.contains(adapter_name), + "shared ACP runtime must not branch on adapter name {adapter_name}; put launch compatibility in the AgentOS-owned package launcher" + ); + } + assert!( + production.contains("ACP_APPEND_SYSTEM_PROMPT_ENV"), + "shared ACP runtime must use the adapter-neutral package-launch contract" + ); +} + +#[test] +fn typescript_sdk_does_not_ship_a_competing_in_memory_vfs() { + let root = repo_root(); + for relative_path in [ + "packages/core/src/runtime-compat.ts", + "packages/core/src/index.ts", + "packages/core/src/layers.ts", + "packages/runtime-core/src/node-runtime.ts", + ] { + let source = std::fs::read_to_string(root.join(relative_path)) + .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); + assert!( + !source.contains("createInMemoryFileSystem") + && !source.contains("class InMemoryFileSystem") + && !source.contains("createInMemoryLayerStore"), + "production TypeScript SDK must not implement or export an in-memory VFS: {relative_path}" + ); + } + assert!( + root.join("packages/runtime-core/src/test-runtime.ts") + .is_file(), + "the explicit test-only VFS callback fixture must remain available" + ); + let low_level_runtime = + std::fs::read_to_string(root.join("packages/runtime-core/src/node-runtime.ts")) + .expect("read low-level Node runtime"); + assert!( + low_level_runtime.contains("filesystem: VirtualFileSystem") + && low_level_runtime.contains("const filesystem = options.filesystem"), + "the low-level compatibility runtime must require a caller-owned filesystem instead of creating a TypeScript default" + ); +} + +#[test] +fn rust_client_transport_routes_live_events_without_history() { + let root = repo_root(); + let source = std::fs::read_to_string(root.join("crates/sidecar-client/src/transport.rs")) + .expect("read Rust sidecar transport"); + for obsolete in [ + "WireEventLog", + "route_sequence", + "global_sequence", + "provisional_process", + ] { + assert!( + !source.contains(obsolete), + "client transport must not retain replay/history state ({obsolete})" + ); + } + assert!( + source.contains("broadcast::channel(EVENT_CHANNEL_CAPACITY)"), + "client transport must retain only bounded live event fan-out" + ); +} + fn native_reactor_source_files(root: &Path) -> Vec { production_source_files(root) .into_iter() @@ -1217,6 +1295,7 @@ fn standalone_wasm_wait_has_no_recurring_adapter_poll() { fn browser_sources_are_retained_but_disabled_from_native_build_and_publish_gates() { let root = repo_root(); for relative_path in [ + "crates/agentos-sidecar-core/src", "crates/agentos-sidecar-browser/src", "crates/native-sidecar-browser/src", "packages/browser/src", @@ -1228,8 +1307,48 @@ fn browser_sources_are_retained_but_disabled_from_native_build_and_publish_gates ); } + for relative_path in [ + "crates/agentos-sidecar-browser/src/lib.rs", + "crates/native-sidecar-browser/src/lib.rs", + "packages/browser/src/index.ts", + "packages/runtime-browser/src/index.ts", + ] { + let source = std::fs::read_to_string(root.join(relative_path)) + .unwrap_or_else(|error| panic!("read {relative_path}: {error}")); + assert!( + source.contains("AGENTOS_BROWSER_SUPPORT_DISABLED"), + "browser public entrypoint must remain disabled: {relative_path}" + ); + assert!( + source.lines().any(|line| line.trim() == "/*"), + "browser public entrypoint source must remain commented out: {relative_path}" + ); + if relative_path.ends_with(".rs") { + assert!( + source.trim_end().ends_with("*/"), + "disabled Rust browser entrypoint must contain no active items after its retained source: {relative_path}" + ); + } else { + assert!( + source.trim_end().ends_with("export {};"), + "disabled TypeScript browser entrypoint must expose only an empty module: {relative_path}" + ); + } + } + let workspace = std::fs::read_to_string(root.join("Cargo.toml")).expect("read workspace Cargo.toml"); + assert!( + workspace.contains("exclude = [\"software\", \"crates/agentos-sidecar-core\"]"), + "the obsolete browser-only ACP state machine must remain outside the native workspace" + ); + let obsolete_core = + std::fs::read_to_string(root.join("crates/agentos-sidecar-core/Cargo.toml")) + .expect("read obsolete browser-only ACP core manifest"); + assert!( + obsolete_core.contains("publish = false"), + "the obsolete browser-only ACP state machine must not be publishable" + ); let default_members = workspace .split("default-members = [") .nth(1) diff --git a/crates/sidecar-client/src/transport.rs b/crates/sidecar-client/src/transport.rs index c220cbca5d..b2ee2c715a 100644 --- a/crates/sidecar-client/src/transport.rs +++ b/crates/sidecar-client/src/transport.rs @@ -770,6 +770,75 @@ mod tests { )); } + #[tokio::test] + async fn event_before_response_is_delivered_without_transport_history() { + let transport = Arc::new(test_transport()); + let mut events = transport.subscribe_wire_events(); + let (response_tx, response_rx) = oneshot::channel(); + transport + .register_pending_request(7, response_tx) + .expect("register pending request"); + let ownership = wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership { + connection_id: "conn-1".to_string(), + }); + + transport + .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame { + schema: wire::protocol_schema(), + ownership: ownership.clone(), + payload: wire::EventPayload::StructuredEvent(wire::StructuredEvent { + name: "process_started_before_reply".to_string(), + detail: std::collections::HashMap::new(), + }), + })) + .await; + transport + .handle_wire_frame(wire::ProtocolFrame::ResponseFrame(wire::ResponseFrame { + schema: wire::protocol_schema(), + request_id: 7, + ownership, + payload: wire::ResponsePayload::ExtEnvelope(wire::ExtEnvelope { + namespace: "test".to_string(), + payload: Vec::new(), + }), + })) + .await; + + let (_, event) = events.recv().await.expect("live event"); + assert!(matches!( + event, + wire::EventPayload::StructuredEvent(wire::StructuredEvent { name, .. }) + if name == "process_started_before_reply" + )); + assert!(matches!( + response_rx.await.expect("response"), + wire::ResponsePayload::ExtEnvelope(_) + )); + } + + #[tokio::test] + async fn bounded_event_fanout_reports_lag_instead_of_retaining_history() { + let (event_tx, mut events) = broadcast::channel(2); + for index in 0..3 { + event_tx + .send(( + wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership { + connection_id: "conn-1".to_string(), + }), + wire::EventPayload::StructuredEvent(wire::StructuredEvent { + name: format!("event-{index}"), + detail: std::collections::HashMap::new(), + }), + )) + .expect("active receiver"); + } + + assert!(matches!( + events.recv().await, + Err(broadcast::error::RecvError::Lagged(1)) + )); + } + #[tokio::test] async fn silence_watchdog_fails_pending_requests_after_sustained_silence() { let transport = Arc::new(test_transport()); diff --git a/docs/thin-client-research/item-93-implementation.md b/docs/thin-client-research/item-93-implementation.md new file mode 100644 index 0000000000..82f3d2fc75 --- /dev/null +++ b/docs/thin-client-research/item-93-implementation.md @@ -0,0 +1,452 @@ +# Item 93 exact implementation brief: sidecar-owned VM runtime/config defaults + +Status: research and implementation brief only. This document does not change +production behavior or mark Item 93 complete. + +Researched on **2026-07-16** against the stack containing Items 1-92 and 100. +Priority is **P1**. Root-cause confidence is **high (99%)** and recommended-fix +confidence is **high (98%)**. + +## Outcome + +Make only the atomic `InitializeVm` request presence-aware: + +1. `runtime` becomes `optional` and `config` becomes + `optional` in the lockstep BARE schema; +2. TypeScript and Rust omit those fields when the caller supplied no runtime or + VM configuration; +3. one helper in `agentos-native-sidecar-core` resolves omission to + `GuestRuntimeKind::JavaScript` and the JSON text `{}`; and +4. native and browser `initialize_vm` call that helper before invoking their + existing `create_vm` implementation. + +This is a move to the shared sidecar/runtime, not a deletion of the defaults. +The sidecar still needs a concrete runtime and a parseable `CreateVmConfig` to +create a VM. The SDKs do not need to choose either value. Do **not** make the +lower-level `CreateVmRequest` optional in this item: it is already the concrete +sidecar-internal creation operation, while `InitializeVm` is the high-level +AgentOS transaction whose omitted values require normalization. + +No compatibility branch is needed. The protocol, clients, native sidecar, and +browser sidecar release in lockstep. + +## Current issue, with exact code + +### TypeScript manufactures both defaults + +`packages/core/src/agent-os.ts`, inside `AgentOs.create`, always constructs a +`CreateVmConfig` and always calls: + +```ts +const nativeVm = await client.initializeVm(session, { + runtime: "java_script", + config: createVmConfig, + // explicit mounts/packages/callbacks follow +}); +``` + +With ordinary omitted VM options, `createVmConfig` serializes as `{}`. Its +`permissions` property is currently inserted with an `undefined` value, which +JSON serialization drops, but the client still authored and forwarded the +empty object. + +`packages/runtime-core/src/sidecar-process.ts`, +`SidecarProcess.initializeVm`, requires both fields in its options type and +copies them into the live request. `packages/runtime-core/src/request-payloads.ts`, +`toGeneratedRequestPayload`, then always converts the runtime and stringifies +the config. + +The existing characterization is visible in +`packages/runtime-core/tests/sidecar-process.test.ts`: the initialization +request is expected to contain `runtime: "java_script"` and `config: {}`. +`packages/runtime-core/tests/request-payloads.test.ts` similarly expects the +generated values `GuestRuntimeKind.JavaScript` and `"{}"`. + +### Rust manufactures the same defaults + +`crates/client/src/agent_os.rs`, `AgentOs::create`, always serializes the result +of `serialize_create_vm_config_for_sidecar`. `CreateVmConfig::default()` has no +present fields and serializes as `{}`, but the string is still put on the wire. +The adjacent request literal always sets: + +```rust +wire::InitializeVmRequest { + runtime: wire::GuestRuntimeKind::JavaScript, + config: create_vm_config, + // presence-aware collections follow +} +``` + +The high-level Rust API does not expose a caller runtime selection, so there is +no caller runtime value to preserve here. It should always send `None`. An +explicit non-default `CreateVmConfig` must remain `Some(exact_json)`. + +### The protocol makes omission impossible + +`crates/sidecar-protocol/protocol/agentos_sidecar_v1.bare` currently declares: + +```bare +type InitializeVmRequest struct { + runtime: GuestRuntimeKind + config: JsonUtf8 + # ... +} +``` + +Consequently the generated Rust fields are concrete and +`packages/runtime-core/src/generated-protocol.ts` exposes concrete +`GuestRuntimeKind` and `JsonUtf8` values. This is why merely changing the two +high-level clients is insufficient. + +### Native and browser currently consume concrete values independently + +`crates/native-sidecar/src/service.rs`, `NativeSidecar::initialize_vm`, and +`crates/native-sidecar-browser/src/wire_dispatch.rs`, +`BrowserWireDispatcher::initialize_vm`, each construct a `CreateVmRequest` +directly from `payload.runtime` and `payload.config`. There is no shared +normalization point today. + +The concrete `create_vm` implementations in +`crates/native-sidecar/src/vm.rs` and +`crates/native-sidecar-browser/src/wire_dispatch.rs` already parse and validate +the config and apply shared environment, permission, filesystem, and limit +defaults. They should remain unchanged. Item 93 only supplies their concrete +runtime/config inputs from one shared omission normalizer. + +## Exact patch + +### 1. Make the generated wire fields optional + +Edit `InitializeVmRequest` in +`crates/sidecar-protocol/protocol/agentos_sidecar_v1.bare`: + +```bare +type InitializeVmRequest struct { + runtime: optional + config: optional + mounts: optional> + packages: optional> + packagesMountAt: optional + hostCallbacks: optional> +} +``` + +Regenerate TypeScript with: + +```sh +pnpm --dir packages/build-tools build:protocol +``` + +Expected generated changes in +`packages/runtime-core/src/generated-protocol.ts` are `GuestRuntimeKind | null` +and `JsonUtf8 | null`, using the generated optional readers/writers. Rust code +is generated at build time from the same schema and becomes +`Option` / `Option` automatically. Do not hand-edit +generated Rust output under `target/`. + +### 2. Add the one shared normalizer + +Add `crates/native-sidecar-core/src/vm_initialization.rs` with a pure helper: + +```rust +use agentos_sidecar_protocol::wire::{ + CreateVmRequest, GuestRuntimeKind, InitializeVmRequest, +}; + +pub fn initialize_vm_create_request(payload: &InitializeVmRequest) -> CreateVmRequest { + CreateVmRequest { + runtime: payload + .runtime + .clone() + .unwrap_or(GuestRuntimeKind::JavaScript), + config: payload + .config + .clone() + .unwrap_or_else(|| String::from("{}")), + } +} +``` + +Declare the module and re-export `initialize_vm_create_request` from +`crates/native-sidecar-core/src/lib.rs`. + +Keep this helper deliberately small. It chooses only the two values that the +concrete `CreateVmRequest` requires; it must not parse config, add environment, +select permissions, bootstrap the filesystem, or duplicate anything already +owned by `create_vm` and shared runtime helpers. + +Add unit tests in the new module that cover all presence combinations: + +- both omitted become JavaScript plus exact `"{}"`; +- explicit WebAssembly/JavaScript runtime survives unchanged; +- explicit config JSON survives byte-for-byte when runtime is omitted; and +- explicit runtime with omitted config gets only the config default. + +Testing the combinations independently prevents an incorrect all-or-nothing +normalizer. + +### 3. Use the helper in both sidecars + +In `crates/native-sidecar/src/service.rs`, replace the inline create request in +`NativeSidecar::initialize_vm`: + +```rust +let create_payload = + agentos_native_sidecar_core::initialize_vm_create_request(&payload); +let created_dispatch = self.create_vm(request, create_payload).await?; +``` + +In `crates/native-sidecar-browser/src/wire_dispatch.rs`, make the corresponding +replacement in `BrowserWireDispatcher::initialize_vm`: + +```rust +let create_payload = + agentos_native_sidecar_core::initialize_vm_create_request(&payload); +let created_dispatch = self.create_vm(request, create_payload); +``` + +The rest of each atomic configure/register/rollback transaction stays exactly +as it is. Borrow the full payload for normalization before moving its mounts, +packages, or callbacks. + +### 4. Preserve omission in runtime-core + +In `packages/runtime-core/src/request-payloads.ts`, make the live payload fields +optional: + +```ts +| { + type: "initialize_vm"; + runtime?: LiveGuestRuntimeKind; + config?: CreateVmConfig; + // existing optional fields + } +``` + +Change the generated conversion to distinguish omission from a present value: + +```ts +runtime: + payload.runtime === undefined + ? null + : toGeneratedGuestRuntimeKind(payload.runtime), +config: + payload.config === undefined + ? null + : stringifyJsonUtf8(payload.config, "initialize VM config"), +``` + +Do not use truthiness: explicit empty configuration `{}` is a present caller +value and must still serialize as `Some("{}")`/`"{}"`. + +In `packages/runtime-core/src/sidecar-process.ts`, make `runtime` and `config` +optional in `SidecarProcess.initializeVm` and conditionally spread only fields +that are not `undefined` into the live request. The transport must not choose a +fallback. + +### 5. Stop TypeScript Core from authoring defaults + +In `packages/core/src/agent-os.ts`, build `createVmConfig` using conditional +spreads for every explicit input. In particular, replace the unconditional +`permissions: sidecarPermissions` property with: + +```ts +...(sidecarPermissions === undefined + ? {} + : { permissions: sidecarPermissions }), +``` + +Remove `runtime: "java_script"` from the `initializeVm` call. Include `config` +only when the built object has at least one own field: + +```ts +const nativeVm = await client.initializeVm(session, { + ...(Object.keys(createVmConfig).length === 0 ? {} : { config: createVmConfig }), + // existing explicit mounts/packages/callbacks +}); +``` + +`Object.keys` is safe only after all absent inputs are conditionally omitted. +Do not stringify and parse the object in Core, and do not drop explicit empty +nested values such as `rootFilesystem: {}` or explicit empty lists. + +### 6. Stop the Rust client from authoring defaults + +In `crates/client/src/agent_os.rs`, retain the typed serialized config long +enough to compare it with its true default: + +```rust +let create_vm_config = serialize_create_vm_config_for_sidecar(&config)?; +let create_vm_config = (create_vm_config != vm_config::CreateVmConfig::default()) + .then(|| { + serde_json::to_string(&create_vm_config).map_err(|error| { + ClientError::Sidecar(format!( + "failed to serialize create VM config: {error}" + )) + }) + }) + .transpose()?; +``` + +Then construct: + +```rust +wire::InitializeVmRequest { + runtime: None, + config: create_vm_config, + // existing presence-aware mounts/packages/callbacks +} +``` + +Typed equality is preferable to checking whether a JSON string equals `"{}"`. +It preserves explicit default-valued presence represented by `Some(...)`, such +as empty loopback ports, explicit root configuration, or explicit nested +runtime configuration. Existing Item 46 tests for explicit presence must stay +green. + +If borrow/move ergonomics make the inline block noisy, extract a private +`serialize_initialize_vm_config` returning `Result, ClientError>` +beside `serialize_create_vm_config_for_sidecar`; do not introduce a new public +client abstraction. + +## Tests to add or update + +### Before-change characterization + +Add these tests before changing production behavior and record their parent +result in the Item 93 tracker row: + +1. **TypeScript high-level capture:** extend + `packages/core/tests/overlay-sidecar-resolution.test.ts` (its fake spawned + `SidecarProcess` already records `initializeVm`) or add a focused + `initialize-vm-omission.test.ts`. Create `AgentOs` with + `defaultSoftware: false` and otherwise omitted VM options. Against the + parent, assert the spy receives `runtime: "java_script"` and `config: {}`. +2. **Rust request builder:** add a private focused builder/test in + `crates/client/src/agent_os.rs` around the `InitializeVmRequest` construction. + Against the parent, the default config produces JavaScript and `"{}"`. + This avoids requiring a real sidecar merely to inspect serialization. + +The after-change expectations for the same tests become absent runtime/config. +Add explicit cases in both clients proving a present nested config remains +present and unchanged. Rust has no public runtime override, so only the shared +normalizer and protocol round-trip tests need explicit non-JavaScript runtime +coverage. + +### Protocol tests + +Update `packages/runtime-core/tests/request-payloads.test.ts`: + +- `{ type: "initialize_vm" }` maps to generated `runtime: null`, `config: null`; +- explicit JavaScript plus `{}` maps to JavaScript plus `"{}"`; and +- explicit non-empty config survives JSON serialization unchanged. + +Update `packages/runtime-core/tests/sidecar-process.test.ts` so +`initializeVm(session, {})` records an `initialize_vm` payload with neither +field. Add an explicit call retaining both values. + +Add a BARE round-trip test in `crates/sidecar-protocol/src/wire.rs` for an +`InitializeVmRequest` with both `None`, then the same frame with explicit +runtime/config. Assert generated -> compat -> generated equality as well as +codec decode equality. This catches either generated endpoint losing presence. + +### Authoritative sidecar tests + +Update `crates/native-sidecar/tests/initialize_vm.rs` helpers to accept optional +runtime/config, then add focused tests: + +- omission initializes successfully and returns the ordinary default resolved + VM view; +- explicit config (for example a raised process retention limit) still changes + the resolved response; and +- the existing atomic rollback/host-callback behavior remains unchanged. + +Update +`crates/native-sidecar-browser/tests/wire_dispatch.rs::browser_wire_dispatcher_initializes_vm_atomically` +to send both values as `None`. Keep/add an explicit-config case proving browser +config still reaches `create_vm`. The pure shared-core tests own exact runtime +selection parity because the browser execution shell does not need to invent a +second runtime-default assertion. + +Do not add default selection tests to the client suites after the change. The +client tests should assert absence; sidecar/core tests should assert the +resolved JavaScript/empty-config behavior. + +## Validation commands + +### Focused before/after commands + +```sh +pnpm --dir packages/runtime-core exec vitest run \ + tests/request-payloads.test.ts tests/sidecar-process.test.ts +pnpm --dir packages/core exec vitest run \ + tests/initialize-vm-omission.test.ts +cargo test -p agentos-client create_vm_config_omits_client_owned_defaults +cargo test -p agentos-sidecar-protocol initialize_vm +cargo test -p agentos-native-sidecar-core initialize_vm +cargo test -p agentos-native-sidecar --test initialize_vm -- --nocapture +cargo test -p agentos-native-sidecar-browser \ + browser_wire_dispatcher_initializes_vm_atomically -- --nocapture +``` + +Use the actual Core test path if the capture case is added to +`overlay-sidecar-resolution.test.ts` instead of a new focused file. + +### Generated, type, and workspace gates + +```sh +pnpm --dir packages/build-tools build:protocol +node scripts/check-generated-artifacts.mjs +pnpm --dir packages/runtime-core check-types +pnpm --dir packages/runtime-core build +pnpm --dir packages/core check-types +pnpm --dir packages/core build +cargo fmt --all -- --check +cargo check -p agentos-sidecar-protocol +cargo check -p agentos-client +cargo check -p agentos-native-sidecar-core +cargo check -p agentos-native-sidecar +cargo check -p agentos-native-sidecar-browser +cargo check --workspace +git diff --check +``` + +Run the repository's known root JavaScript gates if their separately tracked +nested-worktree/npm-shim blockers are cleared; do not weaken those checks in +Item 93. + +## Risks and non-goals + +- **Presence collapse:** `??`, truthiness, JSON-string comparison, or + unconditional `undefined` keys can turn explicit `{}`, `[]`, `false`, or an + explicit default-shaped nested config into omission. Use `=== undefined`, + conditional object construction, and Rust `Option`/typed equality. +- **Divergent defaults:** do not put one fallback in native and another in + browser. Both must call the shared-core helper. +- **Over-broad protocol change:** do not change `CreateVmRequest`; do not make + execute runtime defaults part of this item (those already belong to shared + execute normalization). +- **Config-policy duplication:** the new helper must not deserialize or validate + config. Existing native/browser `create_vm` paths remain authoritative. +- **Generated drift:** schema and committed TypeScript output must be generated + together; Rust output under `target` is never committed. +- **No package-manager exception involved:** this item does not touch + TypeScript's allowed default package list. + +## Completion checklist for the tracker + +- [ ] Parent TypeScript and Rust characterization records show JavaScript plus + `{}` were client-authored. +- [ ] BARE schema and generated TypeScript preserve optional runtime/config. +- [ ] TypeScript high-level and runtime-core requests omit absent values and + preserve every explicit value. +- [ ] Rust sends `None`/`None` for default AgentOS creation and preserves an + explicit non-default config. +- [ ] Shared-core tests prove the one JavaScript/`{}` normalization contract. +- [ ] Native and browser atomic initialization tests consume the shared helper. +- [ ] Client suites contain only omission/forwarding tests for this behavior; + default behavior is tested in shared/sidecar suites. +- [ ] Focused, generated, type/build, formatting, workspace, and diff gates pass. +- [ ] Independent review finds no P0-P2 issue. +- [ ] Item 93 is isolated in its own stacked `jj` revision and its tracker row + is marked `done` with the exact revision ID and validation evidence. diff --git a/docs/thin-client-research/item-94-implementation.md b/docs/thin-client-research/item-94-implementation.md new file mode 100644 index 0000000000..243d63a39c --- /dev/null +++ b/docs/thin-client-research/item-94-implementation.md @@ -0,0 +1,379 @@ +# Item 94 implementation map: sidecar-owned host-tool schema validation + +Researched on **2026-07-16** against working revision `wurwpovw`. This is an +implementation brief only. It does not change production code, tests, or the +tracker status. + +## Decision + +**Priority: P1. Fix confidence: high.** + +Move the Rust compatibility validator to `agentos-native-sidecar-core`, use it +at the native sidecar's last trusted boundary before a host-tool callback is +dispatched, and delete both existing copies: + +- the complete 404-line validator in the Rust client; and +- the separate shallow validator in the native sidecar. + +The protocol already carries each caller-authored schema in +`InitializeVmRequest.host_callbacks`, and the native sidecar already retains +that schema with its VM-owned toolkit registry. No protocol field, default, +client timer, or new sidecar state is needed. + +The Rust client must continue to parse the callback's JSON wire string into a +`serde_json::Value`, find the exact VM-owned host closure, execute it, and +serialize its result. Those are necessary transport/host-resource duties. It +must not independently interpret the schema. + +TypeScript is the intentional exception. It must continue to: + +1. author tools with Zod; +2. project a structural JSON Schema for sidecar registration; and +3. call the complete Zod schema's `safeParseAsync` exactly once before invoking + the host closure. + +The sidecar validator is structural and never runs Zod effects. TypeScript's +single Zod parse remains authoritative for transforms, async refinements, +defaults, stripping, and the callback's typed input. + +## Exact current issue + +### Rust client owns a parallel schema interpreter + +In `crates/client/src/agent_os.rs`: + +- `AgentOs::create()` serializes `HostTool.input_schema` into each + `RegisteredHostCallbackDefinition.input_schema`. This forwarding is correct. +- The same method stores the complete `HostTool`, including its schema, + description, and timeout, in `VmHostToolRegistry.tool_map` after those fields + have already been forwarded. +- `run_host_callback()` parses `HostCallbackRequest.input`, resolves the VM and + callback key, then calls `validate_tool_input(&tool.input_schema, &input)` + before executing the closure. +- `ToolInputSchemaViolation` through `compact_json` are a 404-line private JSON + Schema interpreter. It implements null/empty schemas, `anyOf`, compatibility + `oneOf`, `enum`, `const`, string or array-valued `type`, implicit object + schemas, string lengths, numeric bounds, array bounds/items, object + required/properties, and boolean or schema-valued `additionalProperties`. + Unsupported JSON Schema keywords are silently ignored. + +There is no direct unit coverage for this client-owned validator today. The +only Rust host-tool E2E (`crates/client/tests/os_instructions_e2e.rs`) uses a +simple object schema and valid input, so it does not establish which layer +rejects malformed input or protect the supported subset. + +`crates/client/src/config.rs::HostTool` correctly describes +`input_schema` as a schema forwarded to the sidecar, but the `ToolCallback` +documentation also promises validated JSON. That promise is why deleting +validation outright without an authoritative replacement would be a behavior +regression. + +### Native sidecar already owns the dispatch boundary, but only partially + +In `crates/native-sidecar/src/tools.rs`: + +- `register_host_callbacks()` validates bounded registration shape through + `agentos_native_sidecar_core::tools::validate_toolkit_registration` and stores + the complete registration in `VmState.toolkits`. +- `resolve_toolkit_command()` resolves permissions, parses the retained schema, + parses `--json`, `--json-file`, or schema-derived flags, then calls the local + `validate_tool_input_schema()` before constructing `ToolCommandResolution::Invoke`. +- `spawn_tool_process_events()` in `crates/native-sidecar/src/execution.rs` + dispatches that already-resolved request to the host. This is the correct + authoritative validation point: invalid guest input can fail before a + reverse request is admitted and before any client callback runs. +- The local native validator is only shallow. It checks a root object, + `required`, first-level property types, and boolean + `additionalProperties: false`. It does not enforce nested structures, + branches, constants/enums, lengths, or numeric/array bounds. + +The real native service regression +`tools_javascript_child_process_rejects_invalid_json_file_input_before_dispatch` +already proves this ownership boundary for a wrong first-level integer: the +tool exits nonzero and the host invocation count remains zero. It does not +cover the broader subset currently interpreted by the Rust client. + +### Shared core already owns registration policy + +`crates/native-sidecar-core/src/tools.rs` already owns toolkit/tool name limits, +description/schema/example bounds, timeout limits, registry capacity, command +names, and the host-tool prompt/reference. Both native and browser sidecars +depend on this crate and use `validate_toolkit_registration()`. + +That file is the minimal home for the existing compatibility subset. Adding a +third-party full JSON Schema engine would broaden behavior, add dependency and +compile cost, and make the migration harder to characterize. Item 94 should +move the supported subset without silently changing it. Standards corrections +such as exact-one-match `oneOf` semantics should be a separate tracked change. + +### TypeScript is already on the intended boundary + +In `packages/core/src/agent-os.ts`: + +- `toolToSidecarDefinition()` calls `zodToJsonSchema(tool.inputSchema)` once + while preparing registration. +- `handleHostCallback()` calls + `tool.inputSchema.safeParseAsync(payload.input)` once and executes with + `parsed.data`. + +`packages/core/src/host-tools-zod.ts` deliberately projects only the structural +pre-effect schema. Existing tests in `host-tools-zod.test.ts`, +`toolkit-permissions.test.ts`, and `sidecar-tool-dispatch.test.ts` prove that +conversion does not run effects and that refinements/transforms run exactly +once at callback execution. Do not move or duplicate this Zod behavior. + +## Recommended production edits + +### 1. Put the compatibility validator in shared sidecar core + +File: `crates/native-sidecar-core/src/tools.rs`. + +Move `ToolInputSchemaViolation` and the complete helper chain from +`crates/client/src/agent_os.rs` into this module with behavior and exact error +format preserved. Expose only the narrow public entrypoint and error type: + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolInputSchemaViolation { + path: String, + expected: String, + actual: String, +} + +pub fn validate_tool_input( + schema: &serde_json::Value, + input: &serde_json::Value, +) -> Result<(), ToolInputSchemaViolation> { + validate_tool_input_at_path(schema, input, "$") +} +``` + +Keep the fields private unless a real sidecar consumer needs structured access; +the existing public contract is the stable display text +`ToolInputSchemaViolation at : expected , got `. +Implement `std::error::Error` in shared core so callers can preserve it without +message parsing. + +Export `validate_tool_input` and `ToolInputSchemaViolation` from +`crates/native-sidecar-core/src/lib.rs` beside the other `tools` exports. Do not +add the core crate as a client dependency. + +### 2. Replace the native sidecar's weaker duplicate + +File: `crates/native-sidecar/src/tools.rs`. + +Import the shared entrypoint, preferably aliased as +`core_validate_tool_input`. In `resolve_toolkit_command()`, retain the existing +schema parse and input parse, then replace the local validation call with: + +```rust +if let Err(error) = core_validate_tool_input(&input_schema, &input) { + return Ok(ToolCommandResolution::Failure(error.to_string())); +} +``` + +Delete local `validate_tool_input_schema()` and +`validate_tool_input_value_type()`. Preserve the existing failure path: +invalid input becomes tool stderr plus exit code 1, and no reverse host request +is dispatched. Do not convert a schema violation into a sidecar transport +rejection or a client callback response. + +The browser sidecar has registration but currently no equivalent +schema-derived toolkit command dispatcher. It receives the shared validator +through the common crate without needing a parallel browser state machine or +an unused call. Add no browser-only validation path. + +### 3. Delete Rust client schema interpretation and retained schema state + +File: `crates/client/src/agent_os.rs`. + +- Delete `ToolInputSchemaViolation`, `validate_tool_input`, and every private + validator/description helper through `compact_json`. +- Remove the validation branch from `run_host_callback()`. +- Narrow `VmHostToolRegistry.tool_map` from + `HashMap` to `HashMap`. +- During `AgentOs::create()`, continue serializing the exact + `tool.input_schema` into the wire registration, but store only + `Arc::clone(&tool.execute)` in the host map. +- In `run_host_callback()`, call the resolved closure directly with the decoded + `Value`. + +Update the import from `crate::config` to use `ToolCallback` instead of +retaining `HostTool` solely for the registry. The public `HostTool` config type +and its `input_schema` field remain; callers still need to author and forward a +schema. + +Keep these client checks because they are transport/host ownership, not schema +policy: + +- malformed callback JSON returns `Invalid host callback input`; +- missing VM ownership is rejected; +- unknown VM registry or callback key is rejected; +- callback result serialization failure is returned; and +- the exact callback error string is forwarded. + +No TypeScript production file should change. + +## Before tests: characterize the behavior before moving it + +The parent has no direct Rust validator unit tests, so first add a table-driven +test beside `crates/client/src/agent_os.rs::tests` and run it while the private +validator is still present. Name it +`tool_input_schema_supported_subset_is_characterized` and cover at least: + +- null and `{}` accept arbitrary input; +- `anyOf` and current compatibility `oneOf` accept a matching branch and reject + no-match input; +- `enum`, `const`, and string/array-valued `type`; +- Unicode-aware `minLength`/`maxLength`; +- `minimum`, `exclusiveMinimum`, `maximum`, and `exclusiveMaximum` for number + and integer; +- `minItems`, `maxItems`, recursive `items`, and an indexed error path; +- implicit object shape, missing required property, recursive properties, + `additionalProperties: false`, and schema-valued `additionalProperties`; and +- exact path/expected/actual display output for representative failures. + +Run on the parent implementation: + +```sh +cargo test -p agentos-client --lib tool_input_schema_supported_subset_is_characterized -- --nocapture +``` + +Record the passing command in the Item 94 tracker before moving the test. Then +move the same case table to `crates/native-sidecar-core/src/tools.rs::tests` +rather than maintaining copies in both crates. + +Also retain and record the existing authoritative native characterization: + +```sh +cargo test -p agentos-native-sidecar --test service \ + tools_javascript_child_process_rejects_invalid_json_file_input_before_dispatch \ + -- --nocapture +``` + +## After tests + +### Shared-core ownership + +The moved table test must pass unchanged from shared core: + +```sh +cargo test -p agentos-native-sidecar-core \ + tool_input_schema_supported_subset_is_characterized -- --nocapture +``` + +Add a focused native test beside the existing tool tests for a case the old +native validator missed, such as nested `items` plus `minimum`, and assert: + +1. the process exits 1; +2. stderr contains the exact nested path and bound; +3. the sidecar request handler invocation count remains zero. + +Run the native tool slice: + +```sh +cargo test -p agentos-native-sidecar --test service \ + tools_javascript_child_process -- --nocapture +cargo test -p agentos-native-sidecar tools::tests -- --nocapture +``` + +### Rust forwards the schema but does not interpret callback input + +Add a recording-transport test in `crates/client/src/agent_os.rs::tests` named +`initialize_vm_forwards_host_tool_schema_unchanged`: + +1. Create `SidecarTransport::recording_for_test()`. +2. Construct an explicit `AgentOsSidecar` whose retained + `SharedConnection` uses that transport and a pre-authenticated test + connection id. This can be done inside the crate test module without a new + production constructor. +3. Spawn `AgentOs::create()` with one `HostTool` whose schema exercises nested + objects, arrays, bounds, branches, constants, and additional properties. +4. Decode and answer `OpenSessionRequest`. +5. Decode the following `InitializeVmRequest`, parse the registered + `input_schema` string back to `Value`, and assert equality with the exact + caller-authored `Value`. +6. Abort the still-pending create task after the assertion, before a VM/tool + registry is installed. + +This test must not call the shared validator. It proves the Rust client is a +presence-preserving serializer, not a policy owner. + +Add a source guard (a small repository verifier or a test reading the source) +for the high-value absence claims: + +- `crates/client/src/agent_os.rs` has no `ToolInputSchemaViolation` or + `validate_tool_input`; +- `crates/native-sidecar/src/tools.rs` has no private + `validate_tool_input_schema` or `validate_tool_input_value_type`; and +- `packages/core/src/agent-os.ts` still has exactly one + `.safeParseAsync(payload.input)` call. + +Run: + +```sh +cargo test -p agentos-client --lib initialize_vm_forwards_host_tool_schema_unchanged -- --nocapture +cargo test -p agentos-client --lib +cargo check --workspace +cargo fmt --all -- --check +pnpm --dir packages/core exec vitest run \ + tests/host-tools-zod.test.ts \ + tests/toolkit-permissions.test.ts \ + tests/sidecar-tool-dispatch.test.ts +pnpm --dir packages/core check-types +``` + +`sidecar-tool-dispatch.test.ts` requires the real native sidecar/package +fixture and is the important end-to-end proof that structural sidecar +validation still composes with exactly-once Zod refinement/transform behavior. + +## Risks and non-goals + +- **Do not add `agentos-native-sidecar-core` to `agentos-client`.** That would + merely relocate the code while preserving client-owned behavior. +- **Do not use a full JSON Schema crate in this migration.** It would change + accepted/rejected inputs and possibly error ordering. Preserve the currently + supported subset first. +- **Do not validate schemas at registration by recursively compiling or + caching another representation.** Existing size/depth registration checks + are bounded, and the VM-owned schema string is the source used at dispatch. +- **Do not remove TypeScript Zod validation.** Structural JSON Schema cannot + reproduce transforms, async refinements, defaults, or typed output. +- **Do not retain the complete Rust `HostTool` after registration.** Only its + host closure is inaccessible to the sidecar and therefore legitimately + client-owned. +- The existing validator treats `oneOf` as first-success compatibility rather + than exact-one-match JSON Schema semantics and ignores unsupported keywords. + Preserve that behavior for Item 94; standards corrections require their own + behavior decision and regressions. +- Validation moves earlier for Rust: malformed input fails inside the sidecar + before reverse-request admission instead of returning from the Rust callback. + Assert externally stable tool exit/stderr and zero callback execution rather + than preserving which internal component authored the same violation. + +## Expected diff boundary + +Production: + +- `crates/native-sidecar-core/src/tools.rs` +- `crates/native-sidecar-core/src/lib.rs` +- `crates/native-sidecar/src/tools.rs` +- `crates/client/src/agent_os.rs` +- optionally `crates/client/src/config.rs` for documentation-only wording + +Tests/tracking: + +- shared-core unit tests in `crates/native-sidecar-core/src/tools.rs` +- focused native service/tool tests +- Rust client recording-transport/source-absence coverage +- TypeScript tests run unchanged +- `docs/thin-client-migration.md` + +No protocol schema, generated wire file, TypeScript production file, browser +sidecar production file, package-manager behavior, filesystem behavior, +permission policy, or runtime default should change. + +Implement Item 94 in its own child JJ revision, stacked after the preceding +completed numbered item, and mark its tracker checkboxes complete only after +the before evidence, after evidence, focused real-sidecar regression, +workspace gates, scoped diff review, and independent sub-agent review pass. diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 8efc7e0a48..e34826456e 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -1,3 +1,5 @@ +// AGENTOS_BROWSER_SUPPORT_DISABLED: retained for reference, but AgentOS is native-only. +/* // @rivet-dev/agentos-browser — converged browser runtime for Agent OS. // // The browser runtime is @rivet-dev/agentos-runtime-browser's CONVERGED stack (worker, @@ -53,3 +55,7 @@ export type { AgentOsConvergedSidecarOptions } from "./converged-sidecar.js"; export { createAgentOsConvergedSidecar } from "./converged-sidecar.js"; export type { ConvergedExecutionHostBridge } from "./converged-execution-host-bridge.js"; export { createConvergedExecutionHostBridge } from "./converged-execution-host-bridge.js"; +*/ + +// Keep this file a module while exposing no browser entrypoint. +export {}; diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 942931504e..1d75d5982a 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -497,25 +497,15 @@ function defaultAgentStderrHandler(event: AgentStderrEvent): void { } /** - * Auto-restart outcome reported on an {@link AgentExitEvent}. Mirrors the - * sidecar's `AcpAgentExitedEvent.restart` strings: - * - `"restarted"` — the adapter was respawned and the session was natively - * re-attached under the same session id; the session stays usable. - * - `"unsupported"` — the adapter does not advertise a native resume - * capability (`loadSession`/`resume`); the session was evicted. - * - `"failed"` — the respawn or re-attach errored; the session was evicted. - * - `"exhausted"` — the per-session restart budget was already spent; evicted. + * Restart disposition reported on an {@link AgentExitEvent}. AgentOS never + * respawns an adapter or replays an interrupted request implicitly. */ -export type AgentRestartOutcome = - | "restarted" - | "unsupported" - | "failed" - | "exhausted"; +export type AgentRestartOutcome = "not_attempted"; /** * An unexpected ACP adapter process exit — a crash from the host's * perspective (any spontaneous exit without `closeSession()`, including exit - * code 0) — plus the sidecar's bounded auto-restart outcome. + * code 0). The live route is evicted and must be restored explicitly. */ export interface AgentExitEvent { sessionId: string; @@ -525,11 +515,11 @@ export interface AgentExitEvent { pid: number | null; /** Adapter exit code; `null` when the exit was observed indirectly. */ exitCode: number | null; - /** Auto-restart outcome; only `"restarted"` leaves the session usable. */ + /** Always `"not_attempted"`; AgentOS does not restart adapters implicitly. */ restart: AgentRestartOutcome; - /** Restarts consumed for this session so far. */ + /** Always zero. */ restartCount: number; - /** Per-session restart budget. */ + /** Always zero. */ maxRestarts: number; } @@ -537,7 +527,7 @@ export type AgentExitHandler = (event: AgentExitEvent) => void; function defaultAgentExitHandler(event: AgentExitEvent): void { process.stderr.write( - `[agentos] agent adapter exited unexpectedly: session=${event.sessionId} agent=${event.agentType} exitCode=${event.exitCode ?? "unknown"} restart=${event.restart} (${event.restartCount}/${event.maxRestarts})\n`, + `[agentos] agent adapter exited unexpectedly: session=${event.sessionId} agent=${event.agentType} exitCode=${event.exitCode ?? "unknown"}; restore explicitly before retrying\n`, ); } @@ -632,11 +622,9 @@ export interface AgentOsOptions { onAgentStderr?: AgentStderrHandler; /** * Called when the ACP adapter process behind a session exits without - * `closeSession()` — i.e. an adapter crash. The sidecar auto-restarts the - * adapter (bounded per session, natively re-attaching the same session id) - * and reports the outcome on the event; only `restart === "restarted"` - * leaves the session usable. Defaults to writing a warning line to - * `process.stderr`. + * `closeSession()` — i.e. an adapter crash. The sidecar evicts the live + * route and never retries the adapter or interrupted request implicitly. + * Defaults to writing a warning line to `process.stderr`. */ onAgentExit?: AgentExitHandler; /** @@ -4055,19 +4043,15 @@ export class AgentOs { } private _unsupportedConfigResponse( - agentType: string, + _agentType: string, category: string, ): JsonRpcResponse { - const message = - agentType === "opencode" && category === "model" - ? "OpenCode reports available models, but model switching must be configured before createSession() because ACP session/set_config_option is not implemented." - : `The ${category} config option is read-only for ${agentType} sessions.`; return { jsonrpc: "2.0", id: null, error: { code: -32601, - message, + message: `The ${category} config option is read-only for this session.`, }, }; } diff --git a/packages/core/src/filesystem-snapshot.ts b/packages/core/src/filesystem-snapshot.ts index 0ae42fa7ae..1c4cf3e084 100644 --- a/packages/core/src/filesystem-snapshot.ts +++ b/packages/core/src/filesystem-snapshot.ts @@ -1,8 +1,5 @@ import * as posixPath from "node:path/posix"; -import { - createInMemoryFileSystem, - type VirtualFileSystem, -} from "./runtime-compat.js"; +import type { VirtualFileSystem } from "./runtime-compat.js"; export interface FilesystemEntry { path: string; @@ -15,10 +12,6 @@ export interface FilesystemEntry { target?: string; } -function parseMode(mode: string): number { - return Number.parseInt(mode, 8); -} - export function sortFilesystemEntries( entries: FilesystemEntry[], ): FilesystemEntry[] { @@ -36,67 +29,6 @@ export function sortFilesystemEntries( }); } -async function applyDirectory( - filesystem: VirtualFileSystem, - entry: FilesystemEntry, -): Promise { - if (entry.path !== "/") { - await filesystem.mkdir(entry.path, { recursive: true }); - } - await filesystem.chmod(entry.path, parseMode(entry.mode)); - await filesystem.chown(entry.path, entry.uid, entry.gid); -} - -async function applyFile( - filesystem: VirtualFileSystem, - entry: FilesystemEntry, -): Promise { - const content = entry.content ?? ""; - await filesystem.writeFile( - entry.path, - entry.encoding === "base64" ? Buffer.from(content, "base64") : content, - ); - await filesystem.chmod(entry.path, parseMode(entry.mode)); - await filesystem.chown(entry.path, entry.uid, entry.gid); -} - -async function applySymlink( - filesystem: VirtualFileSystem, - entry: FilesystemEntry, -): Promise { - if (!entry.target) { - throw new Error(`Missing symlink target for ${entry.path}`); - } - await filesystem.symlink(entry.target, entry.path); -} - -export async function createFilesystemFromEntries( - entries: FilesystemEntry[], -): Promise { - const filesystem = createInMemoryFileSystem(); - const sortedEntries = sortFilesystemEntries(entries); - - for (const entry of sortedEntries) { - if (entry.type === "directory") { - await applyDirectory(filesystem, entry); - } - } - - for (const entry of sortedEntries) { - if (entry.type === "file") { - await applyFile(filesystem, entry); - } - } - - for (const entry of sortedEntries) { - if (entry.type === "symlink") { - await applySymlink(filesystem, entry); - } - } - - return filesystem; -} - function toModeString(mode: number): string { return `0${(mode & 0o7777).toString(8)}`; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6283228cd4..8a2ec90a54 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,7 +29,6 @@ export { bindingsSchema, } from "./options-schema.js"; export { - createInMemoryLayerStore, createSnapshotExport, } from "./layers.js"; export { defineSoftware } from "./packages.js"; @@ -43,7 +42,7 @@ export { isAcpTimeoutErrorData, isUnknownSessionErrorData, } from "./json-rpc.js"; -export { createInMemoryFileSystem, KernelError } from "./runtime-compat.js"; +export { KernelError } from "./runtime-compat.js"; export type { ExecOptions, ExecResult, diff --git a/packages/core/src/layers.ts b/packages/core/src/layers.ts index 2f9d5dcfba..f7ba94892f 100644 --- a/packages/core/src/layers.ts +++ b/packages/core/src/layers.ts @@ -1,11 +1,5 @@ -import { randomUUID } from "node:crypto"; import type { BaseFilesystemSnapshot } from "./base-filesystem.js"; -import { - createFilesystemFromEntries, - type FilesystemEntry, - snapshotVirtualFilesystem, -} from "./filesystem-snapshot.js"; -import { createOverlayBackend } from "./overlay-filesystem.js"; +import type { FilesystemEntry } from "./filesystem-snapshot.js"; import type { VirtualFileSystem } from "./runtime-compat.js"; export type OverlayFilesystemMode = "ephemeral" | "read-only"; @@ -44,6 +38,10 @@ export type SnapshotImportSource = } | { kind: "snapshot-export"; source: FilesystemSnapshotExport | unknown }; +/** + * Caller-provided durable layer store. AgentOS forwards these explicit handles + * but does not provide a second client-side filesystem or overlay engine. + */ export interface LayerStore { readonly storeId: string; createWritableLayer(): Promise; @@ -65,97 +63,6 @@ export interface LayerStore { dispose(): void; } -interface WritableLayerState { - kind: "writable"; - // Cleared once the layer is sealed so the heavy filesystem payload is - // released; the (now invalid) state is kept as a tombstone. - fs: VirtualFileSystem | null; - leaseId: string; - valid: boolean; - activeOverlay: VirtualFileSystem | null; -} - -interface SnapshotLayerState { - kind: "snapshot"; - snapshot: FilesystemSnapshotExport; - fs: VirtualFileSystem; -} - -type LayerState = WritableLayerState | SnapshotLayerState; - -function cloneSnapshotHandle( - storeId: string, - layerId: string, -): SnapshotLayerHandle { - return { kind: "snapshot", storeId, layerId }; -} - -function cloneWritableHandle( - storeId: string, - layerId: string, - leaseId: string, -): WritableLayerHandle { - return { kind: "writable", storeId, layerId, leaseId }; -} - -function isBaseFilesystemSnapshot( - value: unknown, -): value is BaseFilesystemSnapshot { - if (!value || typeof value !== "object") { - return false; - } - - const filesystem = (value as { filesystem?: unknown }).filesystem; - if (!filesystem || typeof filesystem !== "object") { - return false; - } - - return Array.isArray((filesystem as { entries?: unknown }).entries); -} - -function isFilesystemSnapshotExport( - value: unknown, -): value is FilesystemSnapshotExport { - if (!value || typeof value !== "object") { - return false; - } - - if ( - (value as { format?: unknown }).format !== "agentos-filesystem-snapshot-v1" - ) { - return false; - } - - const filesystem = (value as { filesystem?: unknown }).filesystem; - if (!filesystem || typeof filesystem !== "object") { - return false; - } - - return Array.isArray((filesystem as { entries?: unknown }).entries); -} - -function normalizeSnapshotExport( - source: SnapshotImportSource, -): FilesystemSnapshotExport { - if (source.kind === "base-filesystem-artifact") { - if (!isBaseFilesystemSnapshot(source.source)) { - throw new Error("Invalid base filesystem artifact"); - } - return { - format: "agentos-filesystem-snapshot-v1", - filesystem: { - entries: source.source.filesystem.entries, - }, - }; - } - - if (!isFilesystemSnapshotExport(source.source)) { - throw new Error("Invalid snapshot export"); - } - - return source.source; -} - export function createSnapshotExport( entries: FilesystemEntry[], ): RootSnapshotExport { @@ -167,171 +74,3 @@ export function createSnapshotExport( }, }; } - -export function createInMemoryLayerStore(): LayerStore { - const storeId = `memory-layer-store:${randomUUID()}`; - const layers = new Map(); - - function getLayerState(handle: LayerHandle): LayerState { - if (handle.storeId !== storeId) { - throw new Error( - `Layer ${handle.layerId} belongs to store ${handle.storeId}, not ${storeId}`, - ); - } - - const state = layers.get(handle.layerId); - if (!state) { - throw new Error(`Unknown layer: ${handle.layerId}`); - } - if (state.kind !== handle.kind) { - throw new Error(`Layer kind mismatch for ${handle.layerId}`); - } - return state; - } - - const store: LayerStore & { - /** test-only: number of layer states still retained by the store */ - readonly retainedLayerCount: number; - } = { - storeId, - - get retainedLayerCount(): number { - return layers.size; - }, - - async createWritableLayer(): Promise { - const layerId = randomUUID(); - const leaseId = randomUUID(); - layers.set(layerId, { - kind: "writable", - fs: await createFilesystemFromEntries([ - { - path: "/", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - ]), - leaseId, - valid: true, - activeOverlay: null, - }); - return cloneWritableHandle(storeId, layerId, leaseId); - }, - - async importSnapshot( - source: SnapshotImportSource, - ): Promise { - const snapshot = normalizeSnapshotExport(source); - const layerId = randomUUID(); - layers.set(layerId, { - kind: "snapshot", - snapshot, - fs: await createFilesystemFromEntries(snapshot.filesystem.entries), - }); - return cloneSnapshotHandle(storeId, layerId); - }, - - async openSnapshotLayer(layerId: string): Promise { - const state = layers.get(layerId); - if (!state || state.kind !== "snapshot") { - throw new Error(`Unknown snapshot layer: ${layerId}`); - } - return cloneSnapshotHandle(storeId, layerId); - }, - - async sealLayer(layer: WritableLayerHandle): Promise { - const state = getLayerState(layer); - if (state.kind !== "writable") { - throw new Error(`Layer ${layer.layerId} is not writable`); - } - const baseFs = state.activeOverlay ?? state.fs; - if (!state.valid || state.leaseId !== layer.leaseId || !baseFs) { - throw new Error(`Writable layer ${layer.layerId} is no longer valid`); - } - - const entries = await snapshotVirtualFilesystem(baseFs); - const snapshot = createSnapshotExport(entries).source; - const layerId = randomUUID(); - - layers.set(layerId, { - kind: "snapshot", - snapshot, - fs: await createFilesystemFromEntries(snapshot.filesystem.entries), - }); - - // Release the writable layer's filesystem payload now that it is sealed; - // the invalid tombstone is retained so stale handles still report cleanly. - state.valid = false; - state.activeOverlay = null; - state.fs = null; - - return cloneSnapshotHandle(storeId, layerId); - }, - - createOverlayFilesystem(options): VirtualFileSystem { - const lowers = options.lowers.map((lower) => { - const state = getLayerState(lower); - if (state.kind !== "snapshot") { - throw new Error(`Layer ${lower.layerId} is not a snapshot`); - } - return lower; - }); - - if (options.mode === "read-only") { - return createOverlayBackend({ - mode: "read-only", - lowers: lowers.map((lower) => { - const state = getLayerState(lower); - if (state.kind !== "snapshot") { - throw new Error(`Layer ${lower.layerId} is not a snapshot`); - } - return state.fs; - }), - }); - } - - const upperState = getLayerState(options.upper); - if (upperState.kind !== "writable") { - throw new Error(`Layer ${options.upper.layerId} is not writable`); - } - if (!upperState.valid || upperState.leaseId !== options.upper.leaseId) { - throw new Error( - `Writable layer ${options.upper.layerId} is no longer valid`, - ); - } - if (upperState.activeOverlay) { - throw new Error( - `Writable layer ${options.upper.layerId} is already attached to an overlay`, - ); - } - if (!upperState.fs) { - throw new Error( - `Writable layer ${options.upper.layerId} is no longer valid`, - ); - } - - const overlay = createOverlayBackend({ - upper: upperState.fs, - lowers: lowers.map((lower) => { - const state = getLayerState(lower); - if (state.kind !== "snapshot") { - throw new Error(`Layer ${lower.layerId} is not a snapshot`); - } - return state.fs; - }), - }); - upperState.activeOverlay = overlay; - return overlay; - }, - - dispose(): void { - // Release every retained layer's filesystem payload so the store does - // not accumulate snapshots/writable layers for the process lifetime. - layers.clear(); - }, - }; - - return store; -} diff --git a/packages/core/src/overlay-filesystem.ts b/packages/core/src/overlay-filesystem.ts deleted file mode 100644 index 7cdf8abbbc..0000000000 --- a/packages/core/src/overlay-filesystem.ts +++ /dev/null @@ -1,782 +0,0 @@ -/** - * Overlay (copy-on-write) filesystem backend. - * - * Layers an optional writable upper filesystem over zero or more lower - * filesystems. Reads resolve from highest precedence to lowest. Writes - * go to the writable upper only, with copy-up and whiteout behavior. - */ - -import * as posixPath from "node:path/posix"; -import { - createInMemoryFileSystem, - KernelError, - type VirtualDirEntry, - type VirtualFileSystem, - type VirtualStat, -} from "./runtime-compat.js"; - -export interface OverlayBackendOptions { - /** Legacy single lower layer. */ - lower?: VirtualFileSystem; - /** Lower layers ordered highest-precedence first. */ - lowers?: VirtualFileSystem[]; - /** Writable upper layer. Defaults to a fresh in-memory filesystem in ephemeral mode. */ - upper?: VirtualFileSystem; - /** Overlay mode. Defaults to ephemeral. */ - mode?: "ephemeral" | "read-only"; -} - -const OVERLAY_METADATA_ROOT = "/.secure-exec-overlay"; -const OVERLAY_WHITEOUT_DIR = "/.secure-exec-overlay/whiteouts"; -const OVERLAY_OPAQUE_DIR = "/.secure-exec-overlay/opaque"; - -type OverlayMarkerKind = "whiteout" | "opaque"; - -export function createOverlayBackend( - options: OverlayBackendOptions, -): VirtualFileSystem { - const mode = options.mode ?? "ephemeral"; - if (mode === "read-only" && options.upper) { - throw new Error("Read-only overlays cannot accept a writable upper layer"); - } - - const configuredLowers = - options.lowers ?? (options.lower ? [options.lower] : []); - const lowers = - configuredLowers.length > 0 - ? configuredLowers - : [createInMemoryFileSystem()]; - const upper = - mode === "read-only" ? null : (options.upper ?? createInMemoryFileSystem()); - - function normPath(path: string): string { - return posixPath.normalize(path); - } - - function isInternalMetadataPath(path: string): boolean { - const normalized = normPath(path); - return ( - normalized === OVERLAY_METADATA_ROOT || - normalized.startsWith(`${OVERLAY_METADATA_ROOT}/`) - ); - } - - function shouldHideDirectoryEntry(path: string, entryName: string): boolean { - return ( - normPath(path) === "/" && - entryName === posixPath.basename(OVERLAY_METADATA_ROOT) - ); - } - - function markerDirectory(kind: OverlayMarkerKind): string { - return kind === "whiteout" ? OVERLAY_WHITEOUT_DIR : OVERLAY_OPAQUE_DIR; - } - - function markerPath(kind: OverlayMarkerKind, path: string): string { - return posixPath.join( - markerDirectory(kind), - Buffer.from(normPath(path)).toString("base64url"), - ); - } - - function throwReadOnly(): never { - throw new KernelError("EROFS", "read-only file system"); - } - - function throwMetadataAccessDenied(path: string, op: string): never { - throw new KernelError("EPERM", `operation not permitted, ${op} '${path}'`); - } - - async function ensureMetadataDirectoriesInUpper(path: string): Promise { - if (!upper) { - throwReadOnly(); - } - await upper.mkdir(OVERLAY_METADATA_ROOT, { recursive: true }); - await upper.mkdir(OVERLAY_WHITEOUT_DIR, { recursive: true }); - await upper.mkdir(OVERLAY_OPAQUE_DIR, { recursive: true }); - } - - async function markerExists( - kind: OverlayMarkerKind, - path: string, - ): Promise { - if (!upper) { - return false; - } - return upper.exists(markerPath(kind, path)); - } - - async function setMarker( - kind: OverlayMarkerKind, - path: string, - present: boolean, - ): Promise { - if (!upper) { - if (present) { - throwReadOnly(); - } - return; - } - - const pathForMarker = markerPath(kind, path); - if (present) { - await ensureMetadataDirectoriesInUpper(path); - await upper.writeFile(pathForMarker, normPath(path)); - return; - } - - if (await upper.exists(pathForMarker)) { - await upper.removeFile(pathForMarker); - } - } - - async function isWhitedOut(path: string): Promise { - return markerExists("whiteout", path); - } - - async function isOpaqueDirectory(path: string): Promise { - return markerExists("opaque", path); - } - - async function addWhiteout(path: string): Promise { - await setMarker("whiteout", path, true); - } - - async function removeWhiteout(path: string): Promise { - await setMarker("whiteout", path, false); - } - - async function markOpaqueDirectory(path: string): Promise { - await setMarker("opaque", path, true); - } - - async function clearOpaqueDirectory(path: string): Promise { - await setMarker("opaque", path, false); - } - - async function clearPathMetadata(path: string): Promise { - await removeWhiteout(path); - await clearOpaqueDirectory(path); - } - - async function existsInFilesystem( - filesystem: VirtualFileSystem, - path: string, - ): Promise { - return hasEntryInFilesystem(filesystem, path); - } - - async function hasEntryInFilesystem( - filesystem: VirtualFileSystem, - path: string, - ): Promise { - try { - if (path === "/") { - await filesystem.stat(path); - } else { - await filesystem.lstat(path); - } - return true; - } catch { - return false; - } - } - - async function existsInUpper(path: string): Promise { - if (!upper) { - return false; - } - return existsInFilesystem(upper, path); - } - - async function hasEntryInUpper(path: string): Promise { - if (!upper) { - return false; - } - return hasEntryInFilesystem(upper, path); - } - - async function findLowerByExists( - path: string, - ): Promise { - for (const lower of lowers) { - if (await existsInFilesystem(lower, path)) { - return lower; - } - } - return null; - } - - async function findLowerByEntry( - path: string, - ): Promise<{ filesystem: VirtualFileSystem; stat: VirtualStat } | null> { - for (const lower of lowers) { - try { - return { - filesystem: lower, - stat: path === "/" ? await lower.stat(path) : await lower.lstat(path), - }; - } catch { - // Try the next lower layer. - } - } - return null; - } - - async function mergedLstat(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await hasEntryInUpper(path)) { - return upper!.lstat(path); - } - const lower = await findLowerByEntry(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.stat; - } - - async function ensureAncestorDirectoriesInUpper(path: string): Promise { - if (!upper) { - throwReadOnly(); - } - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "mkdir"); - } - - const normalized = normPath(path); - const parts = normalized.split("/").filter(Boolean); - let current = ""; - - for (let index = 0; index < parts.length - 1; index++) { - current += `/${parts[index]}`; - if (await existsInUpper(current)) { - continue; - } - - const lower = await findLowerByExists(current); - if (lower) { - const stat = await lower.stat(current); - if (!stat.isDirectory) { - throw new KernelError("ENOTDIR", `not a directory: ${current}`); - } - await upper.mkdir(current); - await upper.chmod(current, stat.mode); - await upper.chown(current, stat.uid, stat.gid); - continue; - } - - await upper.mkdir(current); - } - } - - async function copyUpPath(path: string): Promise { - if (!upper) { - throwReadOnly(); - } - if (await hasEntryInUpper(path)) { - return; - } - - await ensureAncestorDirectoriesInUpper(path); - - const lower = await findLowerByEntry(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - - if (lower.stat.isSymbolicLink) { - const target = await lower.filesystem.readlink(path); - await upper.symlink(target, path); - return; - } - - if (lower.stat.isDirectory) { - await upper.mkdir(path); - await upper.chmod(path, lower.stat.mode); - await upper.chown(path, lower.stat.uid, lower.stat.gid); - await markOpaqueDirectory(path); - return; - } - - const data = await lower.filesystem.readFile(path); - await upper.writeFile(path, data); - await upper.chmod(path, lower.stat.mode); - await upper.chown(path, lower.stat.uid, lower.stat.gid); - } - - async function pathExistsInMergedView(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - return false; - } - if (await hasEntryInUpper(path)) { - return true; - } - return (await findLowerByEntry(path)) !== null; - } - - const backend: VirtualFileSystem = { - async readFile(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await existsInUpper(path)) { - return upper!.readFile(path); - } - const lower = await findLowerByExists(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.readFile(path); - }, - - async readTextFile(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await existsInUpper(path)) { - return upper!.readTextFile(path); - } - const lower = await findLowerByExists(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.readTextFile(path); - }, - - async readDir(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - - let directoryExists = false; - const entries = new Set(); - const includeLowers = !(await isOpaqueDirectory(path)); - - if (includeLowers) { - for (let index = lowers.length - 1; index >= 0; index--) { - try { - const lowerEntries = await lowers[index].readDir(path); - directoryExists = true; - for (const entry of lowerEntries) { - if ( - entry === "." || - entry === ".." || - shouldHideDirectoryEntry(path, entry) - ) - continue; - const childPath = posixPath.join(normPath(path), entry); - if (!(await isWhitedOut(childPath))) { - entries.add(entry); - } - } - } catch { - // This lower does not contribute a directory here. - } - } - } - - if (upper) { - try { - const upperEntries = await upper.readDir(path); - directoryExists = true; - for (const entry of upperEntries) { - if ( - entry === "." || - entry === ".." || - shouldHideDirectoryEntry(path, entry) - ) - continue; - entries.add(entry); - } - } catch { - // No upper directory at this path. - } - } - - if (!directoryExists) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - - return [...entries]; - }, - - async readDirWithTypes(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - - let directoryExists = false; - const entriesByName = new Map(); - const includeLowers = !(await isOpaqueDirectory(path)); - - if (includeLowers) { - for (let index = lowers.length - 1; index >= 0; index--) { - try { - const lowerEntries = await lowers[index].readDirWithTypes(path); - directoryExists = true; - for (const entry of lowerEntries) { - if ( - entry.name === "." || - entry.name === ".." || - shouldHideDirectoryEntry(path, entry.name) - ) - continue; - const childPath = posixPath.join(normPath(path), entry.name); - if (!(await isWhitedOut(childPath))) { - entriesByName.set(entry.name, entry); - } - } - } catch { - // This lower does not contribute a directory here. - } - } - } - - if (upper) { - try { - const upperEntries = await upper.readDirWithTypes(path); - directoryExists = true; - for (const entry of upperEntries) { - if ( - entry.name === "." || - entry.name === ".." || - shouldHideDirectoryEntry(path, entry.name) - ) - continue; - entriesByName.set(entry.name, entry); - } - } catch { - // No upper directory at this path. - } - } - - if (!directoryExists) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - - return [...entriesByName.values()]; - }, - - async writeFile(path: string, content: string | Uint8Array): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "open"); - } - if (!upper) { - throwReadOnly(); - } - await clearPathMetadata(path); - if (await findLowerByEntry(path)) { - await copyUpPath(path); - } else { - await ensureAncestorDirectoriesInUpper(path); - } - return upper.writeFile(path, content); - }, - - async createDir(path: string): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "mkdir"); - } - if (!upper) { - throwReadOnly(); - } - await clearPathMetadata(path); - if (await pathExistsInMergedView(path)) { - throw new KernelError("EEXIST", `file exists: ${path}`); - } - await ensureAncestorDirectoriesInUpper(path); - return upper.createDir(path); - }, - - async mkdir( - path: string, - options?: { recursive?: boolean }, - ): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "mkdir"); - } - await clearPathMetadata(path); - if (await pathExistsInMergedView(path)) { - const stat = await mergedLstat(path); - if (options?.recursive && stat.isDirectory && !stat.isSymbolicLink) { - return; - } - throw new KernelError("EEXIST", `file exists: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (options?.recursive === false) { - const parentPath = posixPath.dirname(normPath(path)); - if (!(await pathExistsInMergedView(parentPath))) { - throw new KernelError("ENOENT", `no such directory: ${parentPath}`); - } - await ensureAncestorDirectoriesInUpper(path); - return upper.createDir(path); - } - await ensureAncestorDirectoriesInUpper(path); - return upper.mkdir(path, options); - }, - - async exists(path: string): Promise { - if (isInternalMetadataPath(path)) { - return false; - } - return pathExistsInMergedView(path); - }, - - async stat(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await existsInUpper(path)) { - return upper!.stat(path); - } - const lower = await findLowerByExists(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.stat(path); - }, - - async removeFile(path: string): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "unlink"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - const lower = await findLowerByExists(path); - const upperExists = await existsInUpper(path); - if (!upperExists && !lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (upperExists) { - await upper.removeFile(path); - } - await clearOpaqueDirectory(path); - await addWhiteout(path); - }, - - async removeDir(path: string): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "rmdir"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - const lower = await findLowerByExists(path); - const upperExists = await existsInUpper(path); - if (!upperExists && !lower) { - throw new KernelError("ENOENT", `no such directory: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (upperExists) { - await upper.removeDir(path); - } - await clearOpaqueDirectory(path); - await addWhiteout(path); - }, - - async rename(oldPath: string, newPath: string): Promise { - if (!upper) { - throwReadOnly(); - } - const data = await backend.readFile(oldPath); - await backend.writeFile(newPath, data); - await backend.removeFile(oldPath); - }, - - async realpath(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await existsInUpper(path)) { - return upper!.realpath(path); - } - const lower = await findLowerByExists(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.realpath(path); - }, - - async symlink(target: string, linkPath: string): Promise { - if (isInternalMetadataPath(linkPath)) { - throwMetadataAccessDenied(linkPath, "symlink"); - } - if (!upper) { - throwReadOnly(); - } - await clearPathMetadata(linkPath); - await ensureAncestorDirectoriesInUpper(linkPath); - return upper.symlink(target, linkPath); - }, - - async readlink(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await hasEntryInUpper(path)) { - return upper!.readlink(path); - } - const lower = await findLowerByEntry(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.filesystem.readlink(path); - }, - - async lstat(path: string): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await hasEntryInUpper(path)) { - return path === "/" ? upper!.stat(path) : upper!.lstat(path); - } - const lower = await findLowerByEntry(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.stat; - }, - - async link(oldPath: string, newPath: string): Promise { - if (isInternalMetadataPath(oldPath) || isInternalMetadataPath(newPath)) { - throwMetadataAccessDenied(newPath, "link"); - } - if (!upper) { - throwReadOnly(); - } - const sourceStat = await mergedLstat(oldPath); - if (sourceStat.isDirectory && !sourceStat.isSymbolicLink) { - throw new KernelError("EPERM", `operation not permitted: ${oldPath}`); - } - await clearPathMetadata(newPath); - await copyUpPath(oldPath); - await ensureAncestorDirectoriesInUpper(newPath); - return upper.link(oldPath, newPath); - }, - - async chmod(path: string, modeValue: number): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "chmod"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (!(await existsInUpper(path))) { - await copyUpPath(path); - } - return upper.chmod(path, modeValue); - }, - - async chown(path: string, uid: number, gid: number): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "chown"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (!(await existsInUpper(path))) { - await copyUpPath(path); - } - return upper.chown(path, uid, gid); - }, - - async utimes(path: string, atime: number, mtime: number): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "utime"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (!(await existsInUpper(path))) { - await copyUpPath(path); - } - await upper.utimes(path, atime, mtime); - const updated = await upper.stat(path); - // Some backends interpret utimes inputs as seconds rather than - // milliseconds. Normalize them here so the overlay presents a - // consistent millisecond-based contract. - if ( - updated.atimeMs === atime * 1000 && - updated.mtimeMs === mtime * 1000 - ) { - await upper.utimes(path, atime / 1000, mtime / 1000); - } - }, - - async truncate(path: string, length: number): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "truncate"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (!(await existsInUpper(path))) { - await copyUpPath(path); - } - return upper.truncate(path, length); - }, - - async pread( - path: string, - offset: number, - length: number, - ): Promise { - if (isInternalMetadataPath(path) || (await isWhitedOut(path))) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (await existsInUpper(path)) { - return upper!.pread(path, offset, length); - } - const lower = await findLowerByExists(path); - if (!lower) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - return lower.pread(path, offset, length); - }, - - async pwrite( - path: string, - offset: number, - data: Uint8Array, - ): Promise { - if (isInternalMetadataPath(path)) { - throwMetadataAccessDenied(path, "pwrite"); - } - if (await isWhitedOut(path)) { - throw new KernelError("ENOENT", `no such file: ${path}`); - } - if (!upper) { - throwReadOnly(); - } - if (!(await existsInUpper(path))) { - await copyUpPath(path); - } - return upper.pwrite(path, offset, data); - }, - }; - - return backend; -} diff --git a/packages/core/src/runtime-compat.ts b/packages/core/src/runtime-compat.ts index 14ff2c125f..8deaa1d849 100644 --- a/packages/core/src/runtime-compat.ts +++ b/packages/core/src/runtime-compat.ts @@ -29,10 +29,6 @@ export const SOCK_STREAM = 1; export const SOCK_DGRAM = 2; export const SIGTERM = 15; -const S_IFREG = 0o100000; -const S_IFDIR = 0o040000; -const S_IFLNK = 0o120000; -const MAX_SYMLINK_DEPTH = 40; const NODE_RUNTIME_BOOTSTRAP_COMMANDS = [ "node", "npm", @@ -565,422 +561,6 @@ function dirnameVirtual(inputPath: string): string { return parts.length <= 1 ? "/" : `/${parts.slice(0, -1).join("/")}`; } -interface FileEntry { - type: "file"; - data: Uint8Array; - mode: number; - uid: number; - gid: number; - nlink: number; - ino: number; - atimeMs: number; - mtimeMs: number; - ctimeMs: number; - birthtimeMs: number; -} - -interface DirectoryEntry { - type: "dir"; - mode: number; - uid: number; - gid: number; - nlink: number; - ino: number; - atimeMs: number; - mtimeMs: number; - ctimeMs: number; - birthtimeMs: number; -} - -interface SymlinkEntry { - type: "symlink"; - target: string; - mode: number; - uid: number; - gid: number; - nlink: number; - ino: number; - atimeMs: number; - mtimeMs: number; - ctimeMs: number; - birthtimeMs: number; -} - -type MemoryEntry = FileEntry | DirectoryEntry | SymlinkEntry; -let nextInode = 1; - -export class InMemoryFileSystem implements VirtualFileSystem { - private readonly entries = new Map(); - - constructor() { - this.entries.set("/", this.newDirectory()); - } - - async readFile(targetPath: string): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry || entry.type !== "file") { - throw errnoError("ENOENT", `open '${targetPath}'`); - } - entry.atimeMs = Date.now(); - return new Uint8Array(entry.data); - } - - async readTextFile(targetPath: string): Promise { - return new TextDecoder().decode(await this.readFile(targetPath)); - } - - async readDir(targetPath: string): Promise { - return (await this.readDirWithTypes(targetPath)).map((entry) => entry.name); - } - - async readDirWithTypes(targetPath: string): Promise { - const resolved = this.resolvePath(targetPath); - const entry = this.entries.get(resolved); - if (!entry || entry.type !== "dir") { - throw errnoError("ENOENT", `scandir '${targetPath}'`); - } - const prefix = resolved === "/" ? "/" : `${resolved}/`; - const output = new Map(); - for (const [entryPath, candidate] of this.entries) { - if (!entryPath.startsWith(prefix)) continue; - const rest = entryPath.slice(prefix.length); - if (!rest || rest.includes("/")) continue; - output.set(rest, { - name: rest, - isDirectory: candidate.type === "dir", - isSymbolicLink: candidate.type === "symlink", - }); - } - return [...output.values()]; - } - - async writeFile( - targetPath: string, - content: string | Uint8Array, - ): Promise { - const normalized = normalizePath(targetPath); - await this.mkdir(dirnameVirtual(normalized), { recursive: true }); - const data = - typeof content === "string" - ? new TextEncoder().encode(content) - : new Uint8Array(content); - const existing = this.entries.get(normalized); - if (existing?.type === "file") { - existing.data = data; - existing.mtimeMs = Date.now(); - existing.ctimeMs = Date.now(); - return; - } - const now = Date.now(); - this.entries.set(normalized, { - type: "file", - data, - mode: S_IFREG | 0o644, - uid: 0, - gid: 0, - nlink: 1, - ino: nextInode++, - atimeMs: now, - mtimeMs: now, - ctimeMs: now, - birthtimeMs: now, - }); - } - - async createDir(targetPath: string): Promise { - const normalized = normalizePath(targetPath); - if (!this.entries.has(dirnameVirtual(normalized))) { - throw errnoError("ENOENT", `mkdir '${targetPath}'`); - } - if (!this.entries.has(normalized)) { - this.entries.set(normalized, this.newDirectory()); - } - } - - async mkdir( - targetPath: string, - options?: { recursive?: boolean }, - ): Promise { - const normalized = normalizePath(targetPath); - if (options?.recursive === false) { - return this.createDir(normalized); - } - let current = ""; - for (const part of normalized.split("/").filter(Boolean)) { - current += `/${part}`; - if (!this.entries.has(current)) { - this.entries.set(current, this.newDirectory()); - } - } - } - - async exists(targetPath: string): Promise { - try { - return this.entries.has(this.resolvePath(targetPath)); - } catch { - return false; - } - } - - async stat(targetPath: string): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry) throw errnoError("ENOENT", `stat '${targetPath}'`); - return this.toStat(entry); - } - - async removeFile(targetPath: string): Promise { - const resolved = normalizePath(targetPath); - const entry = this.entries.get(resolved); - if (!entry || entry.type === "dir") { - throw errnoError("ENOENT", `unlink '${targetPath}'`); - } - this.entries.delete(resolved); - } - - async removeDir(targetPath: string): Promise { - const resolved = this.resolvePath(targetPath); - if (resolved === "/") { - throw errnoError("EPERM", "operation not permitted"); - } - const entry = this.entries.get(resolved); - if (!entry || entry.type !== "dir") { - throw errnoError("ENOENT", `rmdir '${targetPath}'`); - } - const prefix = `${resolved}/`; - for (const key of this.entries.keys()) { - if (key.startsWith(prefix)) { - throw errnoError("ENOTEMPTY", `directory not empty '${targetPath}'`); - } - } - this.entries.delete(resolved); - } - - async rename(oldPath: string, newPath: string): Promise { - const oldResolved = this.resolvePath(oldPath); - const newResolved = normalizePath(newPath); - const entry = this.entries.get(oldResolved); - if (!entry) throw errnoError("ENOENT", `rename '${oldPath}'`); - if (!this.entries.has(dirnameVirtual(newResolved))) { - throw errnoError("ENOENT", `rename '${newPath}'`); - } - if (entry.type !== "dir") { - this.entries.set(newResolved, entry); - this.entries.delete(oldResolved); - return; - } - const prefix = `${oldResolved}/`; - const moved: Array<[string, MemoryEntry]> = []; - for (const candidate of this.entries) { - if (candidate[0] === oldResolved || candidate[0].startsWith(prefix)) { - moved.push(candidate); - } - } - for (const [candidatePath] of moved) { - this.entries.delete(candidatePath); - } - for (const [candidatePath, candidate] of moved) { - const nextPath = - candidatePath === oldResolved - ? newResolved - : `${newResolved}${candidatePath.slice(oldResolved.length)}`; - this.entries.set(nextPath, candidate); - } - } - - async realpath(targetPath: string): Promise { - return this.resolvePath(targetPath); - } - - async symlink(target: string, linkPath: string): Promise { - const normalized = normalizePath(linkPath); - if (this.entries.has(normalized)) { - throw errnoError("EEXIST", `symlink '${linkPath}'`); - } - const now = Date.now(); - this.entries.set(normalized, { - type: "symlink", - target, - mode: S_IFLNK | 0o777, - uid: 0, - gid: 0, - nlink: 1, - ino: nextInode++, - atimeMs: now, - mtimeMs: now, - ctimeMs: now, - birthtimeMs: now, - }); - } - - async readlink(targetPath: string): Promise { - const normalized = normalizePath(targetPath); - const entry = this.entries.get(normalized); - if (!entry || entry.type !== "symlink") { - throw errnoError("ENOENT", `readlink '${targetPath}'`); - } - return entry.target; - } - - async lstat(targetPath: string): Promise { - const entry = this.entries.get(normalizePath(targetPath)); - if (!entry) throw errnoError("ENOENT", `lstat '${targetPath}'`); - return this.toStat(entry); - } - - async link(oldPath: string, newPath: string): Promise { - const entry = this.resolveEntry(oldPath); - if (!entry || entry.type !== "file") { - throw errnoError("ENOENT", `link '${oldPath}'`); - } - const normalized = normalizePath(newPath); - if (this.entries.has(normalized)) { - throw errnoError("EEXIST", `link '${newPath}'`); - } - entry.nlink += 1; - this.entries.set(normalized, entry); - } - - async chmod(targetPath: string, mode: number): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry) throw errnoError("ENOENT", `chmod '${targetPath}'`); - const typeBits = mode & 0o170000; - entry.mode = - typeBits === 0 ? (entry.mode & 0o170000) | (mode & 0o7777) : mode; - entry.ctimeMs = Date.now(); - } - - async chown(targetPath: string, uid: number, gid: number): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry) throw errnoError("ENOENT", `chown '${targetPath}'`); - entry.uid = uid; - entry.gid = gid; - entry.ctimeMs = Date.now(); - } - - async utimes( - targetPath: string, - atime: number, - mtime: number, - ): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry) throw errnoError("ENOENT", `utimes '${targetPath}'`); - entry.atimeMs = atime; - entry.mtimeMs = mtime; - entry.ctimeMs = Date.now(); - } - - async truncate(targetPath: string, length: number): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry || entry.type !== "file") { - throw errnoError("ENOENT", `truncate '${targetPath}'`); - } - if (length < entry.data.length) { - entry.data = entry.data.slice(0, length); - } else if (length > entry.data.length) { - const expanded = new Uint8Array(length); - expanded.set(entry.data); - entry.data = expanded; - } - entry.mtimeMs = Date.now(); - entry.ctimeMs = Date.now(); - } - - async pread( - targetPath: string, - offset: number, - length: number, - ): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry || entry.type !== "file") { - throw errnoError("ENOENT", `open '${targetPath}'`); - } - if (offset >= entry.data.length) return new Uint8Array(0); - return entry.data.slice( - offset, - Math.min(offset + length, entry.data.length), - ); - } - - async pwrite( - targetPath: string, - offset: number, - data: Uint8Array, - ): Promise { - const entry = this.resolveEntry(targetPath); - if (!entry || entry.type !== "file") { - throw errnoError("ENOENT", `open '${targetPath}'`); - } - const nextSize = Math.max(entry.data.length, offset + data.length); - const updated = new Uint8Array(nextSize); - updated.set(entry.data); - updated.set(new Uint8Array(data), offset); - entry.data = updated; - entry.mtimeMs = Date.now(); - entry.ctimeMs = Date.now(); - } - - private resolvePath(targetPath: string, depth = 0): string { - if (depth > MAX_SYMLINK_DEPTH) { - throw errnoError("ELOOP", `too many symbolic links '${targetPath}'`); - } - const normalized = normalizePath(targetPath); - const entry = this.entries.get(normalized); - if (!entry) return normalized; - if (entry.type === "symlink") { - const target = entry.target.startsWith("/") - ? entry.target - : `${dirnameVirtual(normalized)}/${entry.target}`; - return this.resolvePath(target, depth + 1); - } - return normalized; - } - - private resolveEntry(targetPath: string): MemoryEntry | undefined { - return this.entries.get(this.resolvePath(targetPath)); - } - - private newDirectory(): DirectoryEntry { - const now = Date.now(); - return { - type: "dir", - mode: S_IFDIR | 0o755, - uid: 0, - gid: 0, - nlink: 2, - ino: nextInode++, - atimeMs: now, - mtimeMs: now, - ctimeMs: now, - birthtimeMs: now, - }; - } - - private toStat(entry: MemoryEntry): VirtualStat { - const size = entry.type === "file" ? entry.data.length : 4096; - return { - mode: entry.mode, - size, - blocks: size === 0 ? 0 : Math.ceil(size / 512), - dev: 1, - rdev: 0, - isDirectory: entry.type === "dir", - isSymbolicLink: entry.type === "symlink", - atimeMs: entry.atimeMs, - mtimeMs: entry.mtimeMs, - ctimeMs: entry.ctimeMs, - birthtimeMs: entry.birthtimeMs, - ino: entry.ino, - nlink: entry.nlink, - uid: entry.uid, - gid: entry.gid, - }; - } -} - -export function createInMemoryFileSystem(): InMemoryFileSystem { - return new InMemoryFileSystem(); -} - export class NodeFileSystem implements VirtualFileSystem { readonly rootPath: string; diff --git a/packages/core/src/sidecar/agentos-protocol.ts b/packages/core/src/sidecar/agentos-protocol.ts index 5a29049cc8..9c840ae2ce 100644 --- a/packages/core/src/sidecar/agentos-protocol.ts +++ b/packages/core/src/sidecar/agentos-protocol.ts @@ -781,14 +781,10 @@ export function writeAcpAgentStderrEvent(bc: bare.ByteCursor, x: AcpAgentStderrE /** * Emitted when the ACP adapter process exits without an explicit close_session — * a crash from the host's perspective (any spontaneous exit, including code 0). - * `restart` reports the sidecar's bounded auto-restart outcome: - * "restarted" — adapter respawned and the session was natively re-attached - * (session/load | session/resume) under the same sessionId; - * the session stays live. - * "unsupported" — the respawned adapter does not advertise a native resume - * capability; the session record was evicted. - * "failed" — the respawn or the native re-attach errored; evicted. - * "exhausted" — maxRestarts was already spent for this session; evicted. + * `restart` is "not_attempted": the sidecar never respawns an adapter or + * replays an interrupted request implicitly. `restartCount` and `maxRestarts` + * are therefore both zero. Explicit session restoration is a separate caller + * operation. * `exitCode` is absent when the exit was observed indirectly (e.g. a write to * the adapter's stdin failed because the process was already gone). */ diff --git a/packages/core/src/test/runtime.ts b/packages/core/src/test/runtime.ts index 4eb954ff08..baf7dd3ad2 100644 --- a/packages/core/src/test/runtime.ts +++ b/packages/core/src/test/runtime.ts @@ -26,7 +26,6 @@ export { AF_INET, AF_UNIX, allowAll, - createInMemoryFileSystem, createKernel, createNodeHostNetworkAdapter, createNodeRuntime, @@ -38,4 +37,5 @@ export { SOCK_STREAM, WASMVM_COMMANDS, } from "../runtime-compat.js"; +export { createInMemoryFileSystem } from "@rivet-dev/agentos-runtime-core/test-runtime"; export { TerminalHarness } from "./terminal-harness.js"; diff --git a/packages/core/tests/agent-config-environment.test.ts b/packages/core/tests/agent-config-environment.test.ts index dd708bfe52..8f2fa0d1ee 100644 --- a/packages/core/tests/agent-config-environment.test.ts +++ b/packages/core/tests/agent-config-environment.test.ts @@ -14,6 +14,7 @@ const CAPTURED_ENV_KEYS = [ "CLAUDE_CODE_SIMPLE_SHELL_EXEC", "CLAUDE_CODE_SWAP_STDIO", "SHELL", + "ACP_APPEND_SYSTEM_PROMPT", "OPENCODE_CONTEXTPATHS", ] as const; @@ -110,22 +111,22 @@ async function inspectLaunch( } describe("agent launch args and env", () => { - test("Pi SDK injects the system prompt flag", async () => { + test("Pi SDK receives the adapter-neutral system prompt contract", async () => { // The pre-packed pi-sdk adapter embeds the SDK, so (unlike the CLI adapter) // it does NOT need a resolved `PI_ACP_PI_COMMAND` pi binary. const agentInfo = await inspectLaunch("pi"); - expect(agentInfo.argv).toContain("--append-system-prompt"); + expect(agentInfo.env?.ACP_APPEND_SYSTEM_PROMPT).toContain("# agentOS"); }); - test("Pi CLI injects the system prompt flag and resolved pi binary", async () => { + test("Pi CLI receives the system prompt contract 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", { env: { PI_ACP_PI_COMMAND: "pi" }, }); - expect(agentInfo.argv).toContain("--append-system-prompt"); + expect(agentInfo.env?.ACP_APPEND_SYSTEM_PROMPT).toContain("# agentOS"); // The {name,dir} model projects the pi CLI onto $PATH as /opt/agentos/bin/pi, // so pi-cli points PI_ACP_PI_COMMAND at the projected command name (not a host // node_modules path like the old @mariozechner/pi-coding-agent entry). @@ -145,7 +146,7 @@ describe("agent launch args and env", () => { }, }); - expect(agentInfo.argv).toContain("--append-system-prompt"); + expect(agentInfo.env?.ACP_APPEND_SYSTEM_PROMPT).toContain("# agentOS"); expect(agentInfo.env).toMatchObject({ CLAUDE_CODE_DISABLE_CWD_PERSIST: "1", CLAUDE_CODE_DISABLE_DEV_NULL_REDIRECT: "1", @@ -157,18 +158,12 @@ describe("agent launch args and env", () => { }); }); - test("OpenCode passes instruction paths through OPENCODE_CONTEXTPATHS", async () => { + test("OpenCode receives the adapter-neutral system prompt contract", async () => { const agentInfo = await inspectLaunch("opencode"); - const contextPaths = JSON.parse( - agentInfo.env?.OPENCODE_CONTEXTPATHS ?? "[]", - ) as string[]; expect(agentInfo.argv ?? []).not.toContain("--append-system-prompt"); - // The base prompt is injected through a sidecar-materialized file plus the default opencode - // repo-relative markers, not the old baked /etc/agentos path. - expect(contextPaths).toContain("/tmp/agentos-system-prompt.md"); - expect(contextPaths).not.toContain("/etc/agentos/instructions.md"); - expect(contextPaths).toContain("CLAUDE.md"); + expect(agentInfo.env?.ACP_APPEND_SYSTEM_PROMPT).toContain("# agentOS"); + expect(agentInfo.env?.OPENCODE_CONTEXTPATHS).toBeNull(); }); }); diff --git a/packages/core/tests/binding-reference.test.ts b/packages/core/tests/binding-reference.test.ts index 763feca0d7..ecc947a837 100644 --- a/packages/core/tests/binding-reference.test.ts +++ b/packages/core/tests/binding-reference.test.ts @@ -7,7 +7,7 @@ import { } from "./helpers/projected-agent-package.js"; /** - * Mock ACP adapter that answers initialize/session/new and echoes its launch argv in agentInfo so + * Mock ACP adapter that answers initialize/session/new and echoes its launch environment in agentInfo so * the test can assert the sidecar-injected system prompt. */ const MOCK_ACP_ADAPTER = ` @@ -28,7 +28,7 @@ process.stdin.on('data', (chunk) => { let result; switch (msg.method) { case 'initialize': - result = { protocolVersion: 1, agentInfo: { name: 'mock-adapter', version: '1.0', argv: process.argv.slice(2) } }; + result = { protocolVersion: 1, agentInfo: { name: 'mock-adapter', version: '1.0', systemPrompt: process.env.ACP_APPEND_SYSTEM_PROMPT || null } }; break; case 'session/new': result = { sessionId: 'mock-session-1' }; @@ -107,13 +107,9 @@ describe("binding reference registration", () => { test("createSession injects the registered binding reference into the system prompt", async () => { const { sessionId } = await vm.createSession("pi"); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - const argIndex = argv.indexOf("--append-system-prompt"); - expect(argIndex).toBeGreaterThan(-1); - const prompt = argv[argIndex + 1]; + const prompt = agentInfo.systemPrompt ?? ""; expect(prompt).toContain("## Available Host Bindings"); expect(prompt).toContain("`agentos-math add --a --b `"); expect(prompt).toContain("### math"); diff --git a/packages/core/tests/in-memory-filesystem.test.ts b/packages/core/tests/in-memory-filesystem.test.ts deleted file mode 100644 index 6ecfd9d465..0000000000 --- a/packages/core/tests/in-memory-filesystem.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { createInMemoryFileSystem } from "../src/index.js"; - -const decoder = new TextDecoder(); - -describe("InMemoryFileSystem", () => { - test("copies caller-provided write buffers and returned read buffers", async () => { - const fs = createInMemoryFileSystem(); - const input = new Uint8Array([97, 98, 99]); - - await fs.writeFile("/data.txt", input); - input[0] = 120; - - const firstRead = await fs.readFile("/data.txt"); - expect(decoder.decode(firstRead)).toBe("abc"); - - firstRead[1] = 121; - expect(decoder.decode(await fs.readFile("/data.txt"))).toBe("abc"); - }); - - test("removeFile on a symlink removes the link without deleting the target", async () => { - const fs = createInMemoryFileSystem(); - - await fs.writeFile("/target.txt", "target content"); - await fs.symlink("/target.txt", "/link.txt"); - - await fs.removeFile("/link.txt"); - - expect(await fs.exists("/link.txt")).toBe(false); - expect(decoder.decode(await fs.readFile("/target.txt"))).toBe( - "target content", - ); - }); -}); diff --git a/packages/core/tests/layers.test.ts b/packages/core/tests/layers.test.ts deleted file mode 100644 index a0c69cf1b3..0000000000 --- a/packages/core/tests/layers.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - createInMemoryLayerStore, - createSnapshotExport, -} from "../src/index.js"; - -describe("layer store", () => { - test("sealed writable layers become reusable read-only snapshots", async () => { - const store = createInMemoryLayerStore(); - const upper = await store.createWritableLayer(); - const overlay = store.createOverlayFilesystem({ - upper, - lowers: [], - }); - - await overlay.mkdir("/data", { recursive: true }); - await overlay.writeFile("/data/note.txt", "hello from layer"); - - const snapshot = await store.sealLayer(upper); - const reopened = await store.openSnapshotLayer(snapshot.layerId); - const readOnlyOverlay = store.createOverlayFilesystem({ - mode: "read-only", - lowers: [reopened], - }); - - expect(await readOnlyOverlay.readTextFile("/data/note.txt")).toBe( - "hello from layer", - ); - expect(() => - store.createOverlayFilesystem({ - upper, - lowers: [], - }), - ).toThrow("no longer valid"); - }); - - test("imported snapshots compose with a sealed upper snapshot across multiple lowers", async () => { - const store = createInMemoryLayerStore(); - const lower = await store.importSnapshot( - createSnapshotExport([ - { - path: "/", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - { - path: "/workspace", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - { - path: "/workspace/shared.txt", - type: "file", - mode: "0644", - uid: 0, - gid: 0, - content: "lower", - }, - { - path: "/workspace/lower-only.txt", - type: "file", - mode: "0644", - uid: 0, - gid: 0, - content: "lower-only", - }, - ]), - ); - const higher = await store.importSnapshot( - createSnapshotExport([ - { - path: "/", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - { - path: "/workspace", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - { - path: "/workspace/shared.txt", - type: "file", - mode: "0644", - uid: 0, - gid: 0, - content: "higher", - }, - { - path: "/workspace/higher-only.txt", - type: "file", - mode: "0644", - uid: 0, - gid: 0, - content: "higher-only", - }, - ]), - ); - - const upper = await store.createWritableLayer(); - const writableOverlay = store.createOverlayFilesystem({ - upper, - lowers: [higher, lower], - }); - - await writableOverlay.writeFile("/workspace/shared.txt", "upper"); - await writableOverlay.writeFile("/workspace/upper-only.txt", "upper-only"); - - const sealedUpper = await store.sealLayer(upper); - const reopenedUpper = await store.openSnapshotLayer(sealedUpper.layerId); - const readOnlyOverlay = store.createOverlayFilesystem({ - mode: "read-only", - lowers: [reopenedUpper, higher, lower], - }); - - expect(await readOnlyOverlay.readTextFile("/workspace/shared.txt")).toBe( - "upper", - ); - expect( - await readOnlyOverlay.readTextFile("/workspace/higher-only.txt"), - ).toBe("higher-only"); - expect(await readOnlyOverlay.readTextFile("/workspace/lower-only.txt")).toBe( - "lower-only", - ); - expect(await readOnlyOverlay.readTextFile("/workspace/upper-only.txt")).toBe( - "upper-only", - ); - }); -}); diff --git a/packages/core/tests/leak-layer-store.test.ts b/packages/core/tests/leak-layer-store.test.ts deleted file mode 100644 index 50166371b4..0000000000 --- a/packages/core/tests/leak-layer-store.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - createInMemoryLayerStore, - createSnapshotExport, -} from "../src/index.js"; - -// Test-only view onto the in-memory store's internal accounting so we can -// assert the retained-layer map is actually drained. -function retainedLayerCount( - store: ReturnType, -) { - return (store as unknown as { retainedLayerCount: number }) - .retainedLayerCount; -} - -describe("in-memory layer store leak", () => { - test("dispose() releases every retained layer", async () => { - const store = createInMemoryLayerStore(); - - // Accumulate a mix of writable layers and imported snapshots, then seal - // the writable ones (the lifecycle that previously left the map growing). - for (let i = 0; i < 5; i++) { - const writable = await store.createWritableLayer(); - const overlay = store.createOverlayFilesystem({ - upper: writable, - lowers: [], - }); - await overlay.writeFile(`/note-${i}.txt`, `payload ${i}`); - await store.sealLayer(writable); - - await store.importSnapshot( - createSnapshotExport([ - { path: "/", type: "directory", mode: "0755", uid: 0, gid: 0 }, - ]), - ); - } - - // Sanity: the store is holding the accumulated layers before disposal. - expect(retainedLayerCount(store)).toBeGreaterThan(0); - - store.dispose(); - - // Observable leak symptom: after the store's lifecycle ends the tracking - // map must be empty rather than retaining filesystem snapshot payloads. - expect(retainedLayerCount(store)).toBe(0); - }); - - test("sealing a writable layer releases the writable payload while preserving its data", async () => { - const store = createInMemoryLayerStore(); - const writable = await store.createWritableLayer(); - const overlay = store.createOverlayFilesystem({ - upper: writable, - lowers: [], - }); - await overlay.writeFile("/data.txt", "hello"); - - // Before sealing: the store holds exactly the one writable layer. - expect(retainedLayerCount(store)).toBe(1); - - const sealed = await store.sealLayer(writable); - - // Sealing must produce a snapshot layer AND keep the writable as an invalid - // tombstone (so stale handles still report cleanly) — i.e. exactly one new - // retained layer, never deleting the writable outright. - expect(retainedLayerCount(store)).toBe(2); - - // The written payload must have been captured into the sealed snapshot - // BEFORE the writable's filesystem was released — proving the seal both - // snapshotted and then dropped the heavy writable `fs`, not merely flipped - // `valid`. Reading it back through the snapshot is the observable proof the - // payload moved rather than vanished. - const sealedView = store.createOverlayFilesystem({ - mode: "read-only", - lowers: [sealed], - }); - expect(await sealedView.readTextFile("/data.txt")).toBe("hello"); - - // The sealed (now invalid) writable handle must still report cleanly - // instead of leaking its retained overlay filesystem. - expect(() => - store.createOverlayFilesystem({ upper: writable, lowers: [] }), - ).toThrow("no longer valid"); - - // Gap (documented): the literal `state.fs = null` payload-nulling inside - // `sealLayer` is not observable through any public surface — the writable - // tombstone stays in the `layers` map either way, so `retainedLayerCount` - // is unchanged by it, and every public guard already short-circuits on - // `valid === false`. Directly asserting `state.fs === null` would require a - // new production test-only accessor; per the "edit test code only" - // constraint we do not add one. Test 1 (`dispose()` → `retainedLayerCount` - // 0) remains the real guard that retained payloads are dropped, and the - // round-trip above proves the writable payload was moved into the snapshot - // rather than abandoned. - }); -}); diff --git a/packages/core/tests/mount-descriptors.test.ts b/packages/core/tests/mount-descriptors.test.ts index f508982688..4c9fe5903e 100644 --- a/packages/core/tests/mount-descriptors.test.ts +++ b/packages/core/tests/mount-descriptors.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - createHostDirBackend, - createInMemoryFileSystem, -} from "../src/index.js"; +import { createHostDirBackend } from "../src/index.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; import { serializeMountConfigForSidecar } from "../src/sidecar/rpc-client.js"; describe("sidecar mount descriptors", () => { diff --git a/packages/core/tests/mount-reconfigure.test.ts b/packages/core/tests/mount-reconfigure.test.ts index 4f3a5d277b..c3e3f5fb10 100644 --- a/packages/core/tests/mount-reconfigure.test.ts +++ b/packages/core/tests/mount-reconfigure.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createInMemoryFileSystem } from "../src/runtime-compat.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; import { NativeSidecarKernelProxy } from "../src/sidecar/rpc-client.js"; // Regression coverage for post-boot mountFs delivery to the native sidecar: diff --git a/packages/core/tests/mount.test.ts b/packages/core/tests/mount.test.ts index 26880e541b..33e9fbfb15 100644 --- a/packages/core/tests/mount.test.ts +++ b/packages/core/tests/mount.test.ts @@ -1,11 +1,9 @@ import { afterEach, describe, expect, test } from "vitest"; import { AgentOs, - createInMemoryFileSystem, - createInMemoryLayerStore, - createSnapshotExport, type VirtualFileSystem, } from "../src/index.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; const VFS_METHODS = [ "readFile", @@ -228,83 +226,4 @@ describe("mount integration", () => { ).rejects.toThrow("EROFS"); }); - test("declarative overlay mounts create an isolated writable upper", async () => { - const store = createInMemoryLayerStore(); - const lower = await store.importSnapshot({ - kind: "snapshot-export", - source: createSnapshotExport([ - { - path: "/", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - { - path: "/seed.txt", - type: "file", - mode: "0644", - uid: 0, - gid: 0, - content: Buffer.from("seeded").toString("base64"), - encoding: "base64", - }, - ]).source, - }); - - vm = await createMountVm({ - mounts: [ - { - path: "/data", - filesystem: { - type: "overlay", - store, - lowers: [lower], - }, - }, - ], - }); - - expect(new TextDecoder().decode(await vm.readFile("/data/seed.txt"))).toBe( - "seeded", - ); - await vm.writeFile("/data/new.txt", "overlay mount"); - expect(new TextDecoder().decode(await vm.readFile("/data/new.txt"))).toBe( - "overlay mount", - ); - }); - - test("read-only overlay mounts reject writes", async () => { - const store = createInMemoryLayerStore(); - const lower = await store.importSnapshot({ - kind: "snapshot-export", - source: createSnapshotExport([ - { - path: "/", - type: "directory", - mode: "0755", - uid: 0, - gid: 0, - }, - ]).source, - }); - - vm = await createMountVm({ - mounts: [ - { - path: "/data", - filesystem: { - type: "overlay", - store, - mode: "read-only", - lowers: [lower], - }, - }, - ], - }); - - await expect( - vm.writeFile("/data/blocked.txt", "should fail"), - ).rejects.toThrow("EROFS"); - }); }); diff --git a/packages/core/tests/native-sidecar-process.test.ts b/packages/core/tests/native-sidecar-process.test.ts index 7397527d20..aa1c04f576 100644 --- a/packages/core/tests/native-sidecar-process.test.ts +++ b/packages/core/tests/native-sidecar-process.test.ts @@ -18,12 +18,12 @@ import type { CreateVmConfig } from "@rivet-dev/agentos-runtime-core/vm-config"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createHostDirBackend } from "../src/host-dir-mount.js"; import { - createInMemoryFileSystem, createKernel, createNodeRuntime, NodeFileSystem, createWasmVmRuntime, } from "../src/runtime-compat.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; import { NativeSidecarKernelProxy, NativeSidecarProcessClient, diff --git a/packages/core/tests/os-instructions.test.ts b/packages/core/tests/os-instructions.test.ts index 6886fbe33c..d696d1a740 100644 --- a/packages/core/tests/os-instructions.test.ts +++ b/packages/core/tests/os-instructions.test.ts @@ -62,6 +62,7 @@ process.stdin.on('data', (chunk) => { name: 'mock-adapter', version: '1.0', contextPaths: process.env.OPENCODE_CONTEXTPATHS || null, + systemPrompt: process.env.ACP_APPEND_SYSTEM_PROMPT || null, argv: process.argv.slice(2), }, }; @@ -109,16 +110,12 @@ describe("createSession OS instructions integration", () => { } }); - test("createSession with PI passes --append-system-prompt in spawn args", async () => { + test("createSession passes the assembled prompt through the adapter-neutral contract", async () => { const { sessionId } = await vm.createSession("pi"); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - expect(argv).toContain("--append-system-prompt"); - const argIdx = argv.indexOf("--append-system-prompt"); - const instructionsArg = argv[argIdx + 1]; + const instructionsArg = agentInfo.systemPrompt ?? ""; expect(instructionsArg).toBeTruthy(); expect(instructionsArg.length).toBeGreaterThan(0); // The sidecar injects the embedded base prompt, not a guest-read file. @@ -127,26 +124,17 @@ describe("createSession OS instructions integration", () => { vm.closeSession(sessionId); }); - test("createSession with OpenCode passes the sidecar-materialized prompt path in OPENCODE_CONTEXTPATHS", async () => { + test("createSession leaves OpenCode compatibility translation to its launcher", async () => { const { sessionId } = await vm.createSession("opencode"); const agentInfo = vm.getSessionAgentInfo(sessionId) as { contextPaths?: string; + systemPrompt?: 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"); + expect(agentInfo.systemPrompt).toContain("# agentOS"); + expect(agentInfo.contextPaths).toBeNull(); // No .agent-os/ directory created in cwd const agentOsDirExists = await vm.exists("/home/agentos/.agent-os"); @@ -160,11 +148,9 @@ describe("createSession OS instructions integration", () => { skipOsInstructions: true, }); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - expect(argv).not.toContain("--append-system-prompt"); + expect(agentInfo.systemPrompt).toBeNull(); vm.closeSession(sessionId); }); @@ -177,13 +163,9 @@ describe("createSession OS instructions integration", () => { additionalInstructions: additionalText, }); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; + const instructionsArg = agentInfo.systemPrompt ?? ""; expect(instructionsArg).toContain(additionalText); expect(instructionsArg).not.toContain("# agentOS"); @@ -211,13 +193,9 @@ describe("createSession OS instructions integration", () => { additionalInstructions: additionalText, }); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; + const instructionsArg = agentInfo.systemPrompt ?? ""; expect(instructionsArg).toContain(additionalText); vm.closeSession(sessionId); @@ -235,13 +213,9 @@ describe("createSession OS instructions integration", () => { const { sessionId } = await vm.createSession("pi"); const agentInfo = vm.getSessionAgentInfo(sessionId) as { - argv?: string[]; + systemPrompt?: string; }; - const argv = agentInfo.argv ?? []; - - const argIdx = argv.indexOf("--append-system-prompt"); - expect(argIdx).toBeGreaterThan(-1); - const instructionsArg = argv[argIdx + 1]; + const instructionsArg = agentInfo.systemPrompt ?? ""; expect(instructionsArg).toContain(vmLevelInstructions); vm.closeSession(sessionId); diff --git a/packages/core/tests/overlay-backend.test.ts b/packages/core/tests/overlay-backend.test.ts deleted file mode 100644 index fa84fa7537..0000000000 --- a/packages/core/tests/overlay-backend.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { createOverlayBackend } from "../src/overlay-filesystem.js"; -import type { VirtualFileSystem } from "../src/runtime-compat.js"; -import { createInMemoryFileSystem } from "../src/runtime-compat.js"; -import { defineFsDriverTests } from "../src/test/file-system.js"; - -// --------------------------------------------------------------------------- -// Shared VFS conformance tests -// --------------------------------------------------------------------------- - -defineFsDriverTests({ - name: "OverlayBackend", - createFs: () => { - const lower = createInMemoryFileSystem(); - return createOverlayBackend({ lower }); - }, - capabilities: { - symlinks: true, - hardLinks: true, - permissions: true, - utimes: true, - truncate: true, - pread: true, - mkdir: true, - removeDir: true, - allowMissingFileRemoveNoop: false, - allowMissingDirReadAsEmpty: false, - allowMissingSourceRenameNoop: false, - allowDirectoryRenameUnsupported: true, - allowSymlinkLoopErrnoFallback: false, - allowDirectoryHardLink: false, - allowMkdirWithoutRecursiveParentAutoCreate: false, - allowRemoveDirNonEmptyRecursiveDelete: false, - allowSymlinkOverwrite: false, - }, -}); - -// --------------------------------------------------------------------------- -// Overlay-specific tests (layer isolation, whiteouts) -// --------------------------------------------------------------------------- - -describe("OverlayBackend (layer behavior)", () => { - let lower: VirtualFileSystem; - let upper: VirtualFileSystem; - let overlay: VirtualFileSystem; - - beforeEach(async () => { - lower = createInMemoryFileSystem(); - upper = createInMemoryFileSystem(); - - await lower.mkdir("/data", { recursive: true }); - await lower.writeFile("/data/base.txt", "base content"); - await lower.writeFile("/data/shared.txt", "from lower"); - await lower.mkdir("/data/subdir", { recursive: true }); - await lower.writeFile("/data/subdir/nested.txt", "nested in lower"); - - overlay = createOverlayBackend({ lower, upper }); - }); - - test("read from lower when upper doesn't have file", async () => { - const data = await overlay.readFile("/data/base.txt"); - expect(new TextDecoder().decode(data)).toBe("base content"); - }); - - test("read text from lower when upper doesn't have file", async () => { - const text = await overlay.readTextFile("/data/base.txt"); - expect(text).toBe("base content"); - }); - - test("write goes to upper, subsequent read comes from upper", async () => { - await overlay.writeFile("/data/shared.txt", "from upper"); - const text = await overlay.readTextFile("/data/shared.txt"); - expect(text).toBe("from upper"); - }); - - test("write to upper doesn't modify lower", async () => { - await overlay.writeFile("/data/shared.txt", "overwritten"); - - const overlayText = await overlay.readTextFile("/data/shared.txt"); - expect(overlayText).toBe("overwritten"); - - const lowerText = await lower.readTextFile("/data/shared.txt"); - expect(lowerText).toBe("from lower"); - }); - - test("delete creates whiteout, file no longer visible via exists", async () => { - expect(await overlay.exists("/data/base.txt")).toBe(true); - await overlay.removeFile("/data/base.txt"); - expect(await overlay.exists("/data/base.txt")).toBe(false); - expect(await lower.exists("/data/base.txt")).toBe(true); - }); - - test("delete creates whiteout, readFile throws ENOENT", async () => { - await overlay.removeFile("/data/base.txt"); - await expect(overlay.readFile("/data/base.txt")).rejects.toThrow("ENOENT"); - }); - - test("delete creates whiteout, stat throws ENOENT", async () => { - await overlay.removeFile("/data/base.txt"); - await expect(overlay.stat("/data/base.txt")).rejects.toThrow("ENOENT"); - }); - - test("deleting a lower-layer file preserves lower data and does not copy it into upper", async () => { - await overlay.removeFile("/data/base.txt"); - - expect(await overlay.exists("/data/base.txt")).toBe(false); - expect(await lower.readTextFile("/data/base.txt")).toBe("base content"); - expect(await upper.exists("/data/base.txt")).toBe(false); - - const entries = await overlay.readDir("/data"); - expect(entries).not.toContain("base.txt"); - }); - - test("readdir merges both layers and excludes whiteouts", async () => { - await overlay.mkdir("/data", { recursive: true }); - await overlay.writeFile("/data/upper-only.txt", "upper only"); - await overlay.removeFile("/data/base.txt"); - - const entries = await overlay.readDir("/data"); - - expect(entries).toContain("shared.txt"); - expect(entries).toContain("subdir"); - expect(entries).toContain("upper-only.txt"); - expect(entries).not.toContain("base.txt"); - }); - - test("readDirWithTypes merges both layers", async () => { - await overlay.writeFile("/data/extra.txt", "extra"); - const entries = await overlay.readDirWithTypes("/data"); - - const names = entries.map((e) => e.name); - expect(names).toContain("base.txt"); - expect(names).toContain("shared.txt"); - expect(names).toContain("subdir"); - expect(names).toContain("extra.txt"); - - const subdirEntry = entries.find((e) => e.name === "subdir"); - expect(subdirEntry?.isDirectory).toBe(true); - }); - - test("write after delete (whiteout) restores visibility", async () => { - await overlay.removeFile("/data/base.txt"); - expect(await overlay.exists("/data/base.txt")).toBe(false); - - await overlay.writeFile("/data/base.txt", "resurrected"); - expect(await overlay.exists("/data/base.txt")).toBe(true); - - const text = await overlay.readTextFile("/data/base.txt"); - expect(text).toBe("resurrected"); - }); - - test("whiteouts persist when reopening with the same writable upper", async () => { - await overlay.removeFile("/data/base.txt"); - - const reopened = createOverlayBackend({ lower, upper }); - - expect(await reopened.exists("/data/base.txt")).toBe(false); - expect(await reopened.readDir("/data")).not.toContain("base.txt"); - }); - - test("directory copy-up marks the upper directory opaque", async () => { - await overlay.chmod("/data", 0o700); - - expect(await overlay.readDir("/data")).toEqual([]); - expect(await overlay.readDir("/")).not.toContain(".secure-exec-overlay"); - }); - - test("pread falls through to lower", async () => { - const chunk = await overlay.pread("/data/base.txt", 5, 6); - expect(new TextDecoder().decode(chunk)).toBe("conten"); - }); - - test("defaults upper to in-memory filesystem", async () => { - const overlayDefault = createOverlayBackend({ lower }); - await overlayDefault.writeFile("/data/new.txt", "written"); - const text = await overlayDefault.readTextFile("/data/new.txt"); - expect(text).toBe("written"); - - expect(await lower.exists("/data/new.txt")).toBe(false); - }); - - test("mkdir -p on an existing lower directory is a no-op", async () => { - await lower.chmod("/data", 0o2755); - - await overlay.mkdir("/data", { recursive: true }); - - expect(await upper.exists("/data")).toBe(false); - const stat = await overlay.stat("/data"); - expect(stat.mode & 0o7777).toBe(0o2755); - }); - - test("writeFile copies lower parent directory metadata into upper", async () => { - await lower.chmod("/data", 0o2755); - await lower.chown("/data", 1000, 1000); - - await overlay.writeFile("/data/new.txt", "new content"); - - const parentStat = await upper.stat("/data"); - expect(parentStat.mode & 0o7777).toBe(0o2755); - expect(parentStat.uid).toBe(1000); - expect(parentStat.gid).toBe(1000); - }); - - test("mkdir -p on a lower symlink-to-directory preserves the symlink", async () => { - await lower.mkdir("/run/lock", { recursive: true }); - await lower.symlink("../run/lock", "/var-lock"); - - await expect( - overlay.mkdir("/var-lock", { recursive: true }), - ).rejects.toThrow("EEXIST"); - - const stat = await overlay.lstat("/var-lock"); - expect(stat.isSymbolicLink).toBe(true); - expect(await overlay.readlink("/var-lock")).toBe("../run/lock"); - expect(await upper.exists("/var-lock")).toBe(false); - }); - - test("multiple lowers resolve highest-precedence layer first", async () => { - const higher = createInMemoryFileSystem(); - const deeper = createInMemoryFileSystem(); - - await higher.mkdir("/etc", { recursive: true }); - await deeper.mkdir("/etc", { recursive: true }); - await higher.writeFile("/etc/config.txt", "from higher"); - await deeper.writeFile("/etc/config.txt", "from deeper"); - await deeper.writeFile("/etc/deep-only.txt", "deep only"); - - const multiLowerOverlay = createOverlayBackend({ - lowers: [higher, deeper], - }); - - expect(await multiLowerOverlay.readTextFile("/etc/config.txt")).toBe( - "from higher", - ); - expect(await multiLowerOverlay.readTextFile("/etc/deep-only.txt")).toBe( - "deep only", - ); - }); - - test("read-only overlays allow reads and reject writes", async () => { - const readOnlyOverlay = createOverlayBackend({ - mode: "read-only", - lowers: [lower], - }); - - expect(await readOnlyOverlay.readTextFile("/data/base.txt")).toBe( - "base content", - ); - await expect( - readOnlyOverlay.writeFile("/data/new.txt", "blocked"), - ).rejects.toThrow("EROFS"); - }); - - test("read-only mkdir -p on an existing lower directory is a no-op", async () => { - const readOnlyOverlay = createOverlayBackend({ - mode: "read-only", - lowers: [lower], - }); - - await expect( - readOnlyOverlay.mkdir("/data", { recursive: true }), - ).resolves.toBeUndefined(); - }); -}); diff --git a/packages/core/tests/public-api-exports.test.ts b/packages/core/tests/public-api-exports.test.ts index 50265b0927..b068a1b56c 100644 --- a/packages/core/tests/public-api-exports.test.ts +++ b/packages/core/tests/public-api-exports.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import * as publicApi from "../src/index.js"; import { AgentOs, AgentOsSidecar, @@ -11,8 +12,6 @@ import { agentOsLimitsSchema, agentOsOptionsSchema, createHostDirBackend, - createInMemoryFileSystem, - createInMemoryLayerStore, createSnapshotExport, defineSoftware, isPackageDescriptor, @@ -50,6 +49,10 @@ import { } from "../src/index.js"; describe("root public API exports", () => { + test("does not expose client-owned in-memory filesystem factories", () => { + expect(publicApi).not.toHaveProperty("createInMemoryFileSystem"); + expect(publicApi).not.toHaveProperty("createInMemoryLayerStore"); + }); test("re-exports the main public value surface from the root entrypoint", () => { expect(AgentOs).toBeTypeOf("function"); expect(AgentOsSidecar).toBeTypeOf("function"); @@ -97,9 +100,7 @@ describe("root public API exports", () => { expect(parseAgentOsOptions({ defaultSoftware: false })).toEqual({ defaultSoftware: false, }); - expect(createInMemoryFileSystem).toBeTypeOf("function"); expect(KernelError).toBeTypeOf("function"); - expect(createInMemoryLayerStore).toBeTypeOf("function"); expect(createSnapshotExport).toBeTypeOf("function"); // Package dirs are the public software descriptor. expect(defineSoftware("/opt/pkg")).toBe("/opt/pkg"); diff --git a/packages/core/tests/runtime-compat-mount.test.ts b/packages/core/tests/runtime-compat-mount.test.ts index ccfd74d8e6..6f79a80afd 100644 --- a/packages/core/tests/runtime-compat-mount.test.ts +++ b/packages/core/tests/runtime-compat-mount.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "vitest"; import { - createInMemoryFileSystem, createKernel, type Kernel, } from "../src/runtime-compat.js"; +import { createInMemoryFileSystem } from "../src/test/runtime.js"; describe("runtime-compat mountFs bookkeeping", () => { let kernel: Kernel | undefined; diff --git a/packages/runtime-benchmarks/bench-utils.ts b/packages/runtime-benchmarks/bench-utils.ts index b613cfa3b4..e89093e59e 100644 --- a/packages/runtime-benchmarks/bench-utils.ts +++ b/packages/runtime-benchmarks/bench-utils.ts @@ -11,6 +11,7 @@ import { type NodeRuntimeBootTiming, type NodeRuntimeCreateOptions, } from "@rivet-dev/agentos-runtime-core"; +import { createInMemoryFileSystem } from "@rivet-dev/agentos-runtime-core/test-runtime"; function numList(envVar: string, fallback: number[]): number[] { const raw = process.env[envVar]; @@ -69,7 +70,10 @@ export const SCENARIOS: BenchScenario[] = ( export async function createBenchRuntime( options: Pick = {}, ): Promise { - return NodeRuntime.create(options); + return NodeRuntime.create({ + ...options, + filesystem: createInMemoryFileSystem(), + }); } export function createBenchSidecar(): SidecarProcess { diff --git a/packages/runtime-benchmarks/src/lib/vm.ts b/packages/runtime-benchmarks/src/lib/vm.ts index 14ab5a6b50..80b725f6a0 100644 --- a/packages/runtime-benchmarks/src/lib/vm.ts +++ b/packages/runtime-benchmarks/src/lib/vm.ts @@ -11,6 +11,7 @@ import { type SidecarSpawnOptions, type VirtualDirEntry, } from "@rivet-dev/agentos-runtime-core"; +import { createInMemoryFileSystem } from "@rivet-dev/agentos-runtime-core/test-runtime"; import { hasNativeBaselineWasm, supportsWasmLayer } from "./layers.js"; import type { BenchmarkOp, CommandBenchmarkOp } from "./layers.js"; @@ -105,6 +106,7 @@ export interface SidecarBinaryProvenance { export async function createBenchVm(options: BenchVmOptions = {}): Promise { const runtime = await NodeRuntime.create({ + filesystem: createInMemoryFileSystem(), permissions: { fs: "allow", network: "allow", diff --git a/packages/runtime-browser/src/index.ts b/packages/runtime-browser/src/index.ts index 17c10d665b..56ee0b3036 100644 --- a/packages/runtime-browser/src/index.ts +++ b/packages/runtime-browser/src/index.ts @@ -1,3 +1,5 @@ +// AGENTOS_BROWSER_SUPPORT_DISABLED: retained for reference, but AgentOS is native-only. +/* export type { BrowserDriverOptions, BrowserRuntimeSystemOptions, @@ -75,3 +77,7 @@ export { } from "./converged-sync-bridge-handler.js"; export type { ConvergedSidecarRequestTransport } from "./converged-sync-bridge-handler.js"; export type { ConvergedSyncResponse } from "./converged-fs-bridge.js"; +*/ + +// Keep this file a module while exposing no browser entrypoint. +export {}; diff --git a/packages/runtime-core/src/node-runtime-options-schema.ts b/packages/runtime-core/src/node-runtime-options-schema.ts index 6121483c44..713b29665f 100644 --- a/packages/runtime-core/src/node-runtime-options-schema.ts +++ b/packages/runtime-core/src/node-runtime-options-schema.ts @@ -115,6 +115,10 @@ const bindingDefinitionSchema = z */ export const nodeRuntimeCreateOptionsSchema = z .object({ + filesystem: z.custom( + (value: unknown) => typeof value === "object" && value !== null, + { message: "Expected caller-owned VirtualFileSystem object" }, + ), env: z.record(z.string(), z.string()).optional(), cwd: z.string().optional(), permissions: nodeRuntimePermissionsSchema.optional(), @@ -145,7 +149,7 @@ export const nodeRuntimeCreateOptionsSchema = z .strict() as z.ZodType; export function parseNodeRuntimeCreateOptions( - options: NodeRuntimeCreateOptions = {}, + options: NodeRuntimeCreateOptions, ): NodeRuntimeCreateOptions { return nodeRuntimeCreateOptionsSchema.parse(options); } diff --git a/packages/runtime-core/src/node-runtime.ts b/packages/runtime-core/src/node-runtime.ts index 529dba32c6..dabf30b42e 100644 --- a/packages/runtime-core/src/node-runtime.ts +++ b/packages/runtime-core/src/node-runtime.ts @@ -28,11 +28,11 @@ import type { KernelBootTiming, Permissions, VirtualDirEntry, + VirtualFileSystem, } from "./test-runtime.js"; import type { JsRuntimeConfig } from "./generated/JsRuntimeConfig.js"; import type { SidecarProcess } from "./sidecar-process.js"; import { - createInMemoryFileSystem, createKernel, createNodeRuntime, createWasmVmRuntime, @@ -137,6 +137,12 @@ const DEFAULT_PERMISSIONS: Permissions = { * `crates/vm-config/src/lib.rs::CreateVmConfig`. */ export interface NodeRuntimeCreateOptions { + /** + * Caller-owned filesystem used only by this low-level compatibility runtime. + * AgentOS clients do not create a TypeScript filesystem implicitly; normal + * product code should use the sidecar-owned VFS through `AgentOs`. + */ + filesystem: VirtualFileSystem; /** Environment variables visible to guest processes. */ env?: Record; /** Initial working directory for guest processes. Defaults to `/workspace`. */ @@ -579,7 +585,7 @@ export class NodeRuntime { * shell and Node runtimes, and waits for the VM to report ready. */ static async create( - options: NodeRuntimeCreateOptions = {}, + options: NodeRuntimeCreateOptions, ): Promise { options = parseNodeRuntimeCreateOptions(options); const commandsDir = resolveNodeRuntimeCommandsDir(options.commandsDir); @@ -588,7 +594,7 @@ export class NodeRuntime { // boot so they are part of the root filesystem snapshot the guest sees // (e.g. projected npm packages or fixtures). The host filesystem is // never exposed; only these bytes are copied in. - const filesystem = createInMemoryFileSystem(); + const filesystem = options.filesystem; if (options.files) { for (const [filePath, content] of Object.entries(options.files)) { await filesystem.writeFile(filePath, content); diff --git a/packages/runtime-core/tests/node-runtime-exec-output.test.ts b/packages/runtime-core/tests/node-runtime-exec-output.test.ts index e1f933538c..3a8e48d567 100644 --- a/packages/runtime-core/tests/node-runtime-exec-output.test.ts +++ b/packages/runtime-core/tests/node-runtime-exec-output.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "vitest"; import { NodeRuntime } from "../src/index.js"; +import { createInMemoryFileSystem } from "../src/test-runtime.js"; describe("NodeRuntime execCommand output capture", () => { test( "captures complete stdout when a fast process exits immediately", async () => { - const runtime = await NodeRuntime.create(); + const runtime = await NodeRuntime.create({ + filesystem: createInMemoryFileSystem(), + }); const expected = "x".repeat(64 * 1024); const script = [ 'const fs = require("node:fs");', diff --git a/packages/runtime-core/tests/node-runtime-options-schema.test.ts b/packages/runtime-core/tests/node-runtime-options-schema.test.ts index d941cd8248..c85fecb2c3 100644 --- a/packages/runtime-core/tests/node-runtime-options-schema.test.ts +++ b/packages/runtime-core/tests/node-runtime-options-schema.test.ts @@ -3,11 +3,13 @@ import { NodeRuntime, nodeRuntimeCreateOptionsSchema, } from "../src/index.js"; +import { createInMemoryFileSystem } from "../src/test-runtime.js"; describe("NodeRuntime create options validation", () => { test("rejects unknown top-level options before booting a VM", async () => { await expect( NodeRuntime.create({ + filesystem: createInMemoryFileSystem(), notARealOption: true, } as never), ).rejects.toThrow(/notARealOption/); @@ -16,6 +18,7 @@ describe("NodeRuntime create options validation", () => { test("rejects unknown nested permission fields", () => { expect(() => nodeRuntimeCreateOptionsSchema.parse({ + filesystem: createInMemoryFileSystem(), permissions: { filesystem: "allow", }, 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 b4c041d875..eba7435898 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 @@ -5,12 +5,12 @@ import { nodeModulesMount } from "@rivet-dev/agentos-core"; import { generateText, stepCountIs, tool } from "ai"; import { allowAll, - createInMemoryFileSystem, createKernel, createNodeDriver, createNodeRuntime, createNodeRuntimeDriverFactory, } from "@rivet-dev/agentos-core/internal/runtime-compat"; +import { createInMemoryFileSystem } from "@rivet-dev/agentos-core/test/runtime"; import { z } from "zod"; const filesystem = createInMemoryFileSystem(); diff --git a/packages/secure-exec/src/index.ts b/packages/secure-exec/src/index.ts index 9036ec4272..4714b3609f 100644 --- a/packages/secure-exec/src/index.ts +++ b/packages/secure-exec/src/index.ts @@ -39,7 +39,6 @@ export { allowAllFs, allowAllNetwork, createDefaultNetworkAdapter, - createInMemoryFileSystem, createKernel, createNodeDriver, createNodeHostCommandExecutor, diff --git a/packages/secure-exec/tests/public-api.test.ts b/packages/secure-exec/tests/public-api.test.ts index 15ad9ee860..e15d195bcb 100644 --- a/packages/secure-exec/tests/public-api.test.ts +++ b/packages/secure-exec/tests/public-api.test.ts @@ -10,7 +10,6 @@ import { allowAllFs as coreAllowAllFs, allowAllNetwork as coreAllowAllNetwork, createDefaultNetworkAdapter as coreCreateDefaultNetworkAdapter, - createInMemoryFileSystem as coreCreateInMemoryFileSystem, createKernel as coreCreateKernel, createNodeDriver as coreCreateNodeDriver, createNodeHostCommandExecutor as coreCreateNodeHostCommandExecutor, @@ -66,9 +65,6 @@ describe("secure-exec", () => { coreCreateNodeRuntimeDriverFactory, ); expect(secureExec.createKernel).toBe(coreCreateKernel); - expect(secureExec.createInMemoryFileSystem).toBe( - coreCreateInMemoryFileSystem, - ); expect(secureExec.allowAll).toBe(coreAllowAll); expect(secureExec.allowAllFs).toBe(coreAllowAllFs); expect(secureExec.allowAllNetwork).toBe(coreAllowAllNetwork); @@ -121,4 +117,8 @@ describe("secure-exec", () => { await expect(importDeferred("secure-exec/browser")).rejects.toThrow(); await expect(importDeferred("secure-exec/python")).rejects.toThrow(); }); + + it("does not expose an in-memory filesystem factory", () => { + expect(secureExec).not.toHaveProperty("createInMemoryFileSystem"); + }); }); diff --git a/packages/secure-exec/tests/quickstart-smoke.ts b/packages/secure-exec/tests/quickstart-smoke.ts index 505fec5163..c44f26f65c 100644 --- a/packages/secure-exec/tests/quickstart-smoke.ts +++ b/packages/secure-exec/tests/quickstart-smoke.ts @@ -1,12 +1,12 @@ import { allowAll, - createInMemoryFileSystem, createNodeDriver, createNodeHostCommandExecutor, createNodeRuntimeDriverFactory, NodeRuntime, type NodeRuntimeOptions, } from "secure-exec"; +import { createInMemoryFileSystem } from "@rivet-dev/agentos-core/test/runtime"; export function createQuickstartOptions(): NodeRuntimeOptions { const filesystem = createInMemoryFileSystem(); diff --git a/packages/typescript/tests/typescript-tools.integration.test.ts b/packages/typescript/tests/typescript-tools.integration.test.ts index 4f9b0332b5..9b80bbc2b5 100644 --- a/packages/typescript/tests/typescript-tools.integration.test.ts +++ b/packages/typescript/tests/typescript-tools.integration.test.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import { createTypeScriptTools } from "@rivet-dev/agentos-typescript"; import { allowAllFs, - createInMemoryFileSystem, createKernel, createNodeDriver, createNodeRuntime, @@ -11,6 +10,7 @@ import { nodeModulesMount, type NodeRuntimeDriverFactory, } from "@rivet-dev/agentos-core/internal/runtime-compat"; +import { createInMemoryFileSystem } from "@rivet-dev/agentos-core/test/runtime"; import { describe, expect, it } from "vitest"; const workspaceRoot = resolve( diff --git a/registry/software/codex-cli/bin/codex-exec b/registry/software/codex-cli/bin/codex-exec new file mode 100644 index 0000000000..5c27fbc846 Binary files /dev/null and b/registry/software/codex-cli/bin/codex-exec differ diff --git a/scripts/publish/src/lib/rust-crates.test.ts b/scripts/publish/src/lib/rust-crates.test.ts index 99dbd68178..f8ce3b0afa 100644 --- a/scripts/publish/src/lib/rust-crates.test.ts +++ b/scripts/publish/src/lib/rust-crates.test.ts @@ -42,6 +42,7 @@ test("Rust crate publish order satisfies internal dependencies", () => { assert.equal(new Set(RUST_CRATES).size, RUST_CRATES.length); assert(!RUST_CRATES.includes("agentos-sidecar-browser" as never)); assert(!RUST_CRATES.includes("agentos-native-sidecar-browser" as never)); + assert(!RUST_CRATES.includes("agentos-sidecar-core" as never)); assertBefore("agentos-build-support", "agentos-v8-runtime"); assertBefore("agentos-actor-uds-client", "agentos-native-sidecar"); @@ -56,7 +57,6 @@ test("Rust crate publish order satisfies internal dependencies", () => { assertBefore("agentos-execution", "agentos-native-sidecar"); assertBefore("agentos-native-sidecar-core", "agentos-native-sidecar"); assertBefore("agentos-sidecar-client", "agentos-native-sidecar"); - assertBefore("agentos-sidecar-core", "agentos-sidecar"); assertBefore("agentos-protocol", "agentos-client"); assertBefore("agentos-client", "agentos-sidecar"); }); diff --git a/scripts/publish/src/lib/rust-crates.ts b/scripts/publish/src/lib/rust-crates.ts index 39420c2fd9..4e45fb56e3 100644 --- a/scripts/publish/src/lib/rust-crates.ts +++ b/scripts/publish/src/lib/rust-crates.ts @@ -19,7 +19,6 @@ export const RUST_CRATE_ORDER = [ "agentos-sidecar-client", "agentos-native-sidecar", "agentos-protocol", - "agentos-sidecar-core", "agentos-client", "agentos-sidecar", ] as const; diff --git a/software/claude/src/adapter.ts b/software/claude/src/adapter.ts index 99e87d2b85..54ffb3acba 100644 --- a/software/claude/src/adapter.ts +++ b/software/claude/src/adapter.ts @@ -62,6 +62,7 @@ for (let i = 0; i < argv.length; i++) { i++; } } +appendSystemPrompt ??= process.env.ACP_APPEND_SYSTEM_PROMPT; const claudeSdkRuntimePromise = loadPatchedClaudeSdkRuntime(); const traceAdapterMessages = diff --git a/software/opencode/src/adapter.ts b/software/opencode/src/adapter.ts index 0ac8814398..3f7aa2c777 100644 --- a/software/opencode/src/adapter.ts +++ b/software/opencode/src/adapter.ts @@ -1,7 +1,29 @@ #!/usr/bin/env node +import { writeFileSync } from "node:fs"; + process.env.OPENCODE_DISABLE_CONFIG_DEP_INSTALL ??= "1"; process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI ??= "1"; +const agentOsPrompt = process.env.ACP_APPEND_SYSTEM_PROMPT; +if (agentOsPrompt && !process.env.OPENCODE_CONTEXTPATHS) { + const promptPath = "/tmp/agentos-system-prompt.md"; + writeFileSync(promptPath, agentOsPrompt); + process.env.OPENCODE_CONTEXTPATHS = JSON.stringify([ + ".github/copilot-instructions.md", + ".cursorrules", + ".cursor/rules/", + "CLAUDE.md", + "CLAUDE.local.md", + "opencode.md", + "opencode.local.md", + "OpenCode.md", + "OpenCode.local.md", + "OPENCODE.md", + "OPENCODE.local.md", + promptPath, + ]); +} + // @ts-expect-error Generated at build time by scripts/build-opencode-acp.mjs. const { AcpCommand } = (await import("./opencode-acp/acp.js")) as { AcpCommand: { diff --git a/software/pi-cli/agentos-package.json b/software/pi-cli/agentos-package.json index 9d25dd6f54..e197688923 100644 --- a/software/pi-cli/agentos-package.json +++ b/software/pi-cli/agentos-package.json @@ -1,7 +1,7 @@ { "name": "pi-cli", "agent": { - "acpEntrypoint": "pi-acp", + "acpEntrypoint": "agentos-pi-acp", "env": { "PI_ACP_PI_COMMAND": "pi" } diff --git a/software/pi-cli/package.json b/software/pi-cli/package.json index 26fee94418..f8b97e8404 100644 --- a/software/pi-cli/package.json +++ b/software/pi-cli/package.json @@ -6,6 +6,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "bin": { + "agentos-pi-acp": "./dist/adapter.js", "pi-acp": "./node_modules/pi-acp/dist/index.js", "pi": "./node_modules/@mariozechner/pi-coding-agent/dist/cli.js" }, @@ -23,7 +24,7 @@ "agentos-package.json" ], "scripts": { - "build": "tsc && rm -rf dist/package dist/package.tar && agentos-toolchain pack . --out dist/package --agent pi-acp --prune-native", + "build": "tsc && rm -rf dist/package dist/package.tar && agentos-toolchain pack . --out dist/package --agent agentos-pi-acp --prune-native", "check-types": "tsc --noEmit", "test": "vitest run test/ --passWithNoTests" }, diff --git a/software/pi-cli/src/adapter.ts b/software/pi-cli/src/adapter.ts new file mode 100644 index 0000000000..52b805e428 --- /dev/null +++ b/software/pi-cli/src/adapter.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +const prompt = process.env.ACP_APPEND_SYSTEM_PROMPT; +if (prompt) { + process.argv.push("--append-system-prompt", prompt); +} + +// The AgentOS-owned launcher only translates the generic launch contract. The +// actual ACP implementation remains the upstream pi-acp package. +// @ts-expect-error pi-acp does not publish declarations for its CLI entrypoint. +await import("pi-acp/dist/index.js"); diff --git a/software/pi/src/adapter.ts b/software/pi/src/adapter.ts index c544c8bd9d..b41f09c666 100644 --- a/software/pi/src/adapter.ts +++ b/software/pi/src/adapter.ts @@ -778,6 +778,7 @@ for (let i = 0; i < argv.length; i++) { i++; } } +appendSystemPrompt ??= process.env.ACP_APPEND_SYSTEM_PROMPT; // ── Agent implementation ────────────────────────────────────────────