Skip to content

Commit 98c7a13

Browse files
committed
feat(packages): package projection in sidecar + agentos-toolchain, registry {name,dir} migration, single base-filesystem
- Move package projection into the secure-exec sidecar (ConfigureVm.packages + LinkPackage wire request); clients forward, no client-side staging - agentos-toolchain (pack) now lives in secure-exec; flat --out, --bundle removed, native-addon error names --prune-native - Remove agentos-package.json manifest (package.json "bin" is the source); npm-shippable output (no bin/ symlinks) - Migrate all registry software+agent packages to the {name,dir} descriptor with a clean dist/package build dir - Rename @secure-exec/registry-types -> @agentos-software/manifest - Remove file-system registry packages (s3/google-drive mounts are core) - Single base-filesystem.json (direct include_str!, no build.rs); python3 shebang -> Pyodide file dispatch
1 parent a8b74a4 commit 98c7a13

329 files changed

Lines changed: 3274 additions & 4179 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Every bound that protects a shared resource — memory/heap, CPU/wall-clock, fd/
6060
## Build And Assets
6161

6262
- The VM base filesystem artifact is derived from Alpine Linux, but runtime source should stay generic.
63-
- Rebuild the base filesystem with `pnpm --dir packages/build-tools snapshot:alpine-defaults`, then `pnpm --dir packages/build-tools build:base-filesystem`.
63+
- Rebuild the base filesystem (requires Docker) with `pnpm --dir packages/build-tools build:base-filesystem`. The one script snapshots Alpine, applies the secure-exec transforms, and writes the single canonical `packages/core/fixtures/base-filesystem.json`, mirroring the same bytes into the crate-vendored `crates/sidecar/assets/` and `crates/vfs/assets/` copies (those exist only as the `cargo publish` fallback; never hand-edit them).
6464
- The V8 bridge bundle is generated from `packages/build-tools/scripts/build-v8-bridge.mjs`; keep its generated assets aligned with bridge-contract changes.
6565
- `registry/native` owns the Rust-to-WASM command build; package-local `registry/software/*/wasm/` output is release material.
6666

crates/execution/src/javascript.rs

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,12 +1808,26 @@ impl JavascriptExecutionEngine {
18081808

18091809
// Build user code: prefer inline code, fall back to entrypoint-based
18101810
let translator = GuestPathTranslator::from_request(&request);
1811-
let host_entrypoint = translator.resolve_host_entrypoint(&request.cwd, &request.argv[0]);
1812-
let guest_entrypoint = if request.argv[0] == "-e" || request.argv[0] == "--eval" {
1811+
let mut host_entrypoint =
1812+
translator.resolve_host_entrypoint(&request.cwd, &request.argv[0]);
1813+
let mut guest_entrypoint = if request.argv[0] == "-e" || request.argv[0] == "--eval" {
18131814
request.argv[0].clone()
18141815
} else {
18151816
translator.host_to_guest_string(&host_entrypoint)
18161817
};
1818+
// Run an `/opt/agentos` projected command from its REALPATH: the
1819+
// `/opt/agentos/bin/<cmd>` entrypoint is a symlink into the package, so
1820+
// following it (like Node's default `preserveSymlinks=false`) makes the
1821+
// runtime read the real file — its `import`s resolve against the package's
1822+
// own `node_modules` and its module mode is read from the real
1823+
// `package.json`. Scoped to `/opt/agentos` so the legacy
1824+
// `/root/node_modules` (pnpm) flow is untouched.
1825+
if guest_entrypoint.starts_with("/opt/agentos/") {
1826+
if let Ok(canonical) = std::fs::canonicalize(&host_entrypoint) {
1827+
guest_entrypoint = translator.host_to_guest_string(&canonical);
1828+
host_entrypoint = canonical;
1829+
}
1830+
}
18171831
let process_argv = if matches!(guest_entrypoint.as_str(), "-e" | "--eval") {
18181832
std::iter::once(String::from("node"))
18191833
.chain(request.argv.iter().skip(1).cloned())
@@ -1834,12 +1848,21 @@ impl JavascriptExecutionEngine {
18341848
.is_some_and(inline_code_uses_module_mode);
18351849
if !matches!(guest_entrypoint.as_str(), "-e" | "--eval") && !use_module_mode {
18361850
if let Some(inline_code) = inline_code.as_ref() {
1837-
if let Some(parent) = host_entrypoint.parent() {
1838-
fs::create_dir_all(parent)
1851+
// Only materialize the import cache for a SYNTHESIZED entrypoint
1852+
// (no file on disk). Never overwrite an existing entrypoint: it may
1853+
// be a read-only mounted package command (e.g. `/opt/agentos/bin/*`),
1854+
// and writing the shebang-stripped `inline_code` over it corrupts
1855+
// the file so a second exec sees no `#!` and fails with ENOEXEC.
1856+
// `require()` strips the shebang in-process, so the on-disk file
1857+
// keeps its shebang and stays re-executable.
1858+
if !host_entrypoint.exists() {
1859+
if let Some(parent) = host_entrypoint.parent() {
1860+
fs::create_dir_all(parent)
1861+
.map_err(JavascriptExecutionError::PrepareImportCache)?;
1862+
}
1863+
fs::write(&host_entrypoint, inline_code)
18391864
.map_err(JavascriptExecutionError::PrepareImportCache)?;
18401865
}
1841-
fs::write(&host_entrypoint, inline_code)
1842-
.map_err(JavascriptExecutionError::PrepareImportCache)?;
18431866
}
18441867
}
18451868
let user_code = if matches!(guest_entrypoint.as_str(), "-e" | "--eval") {
@@ -2210,9 +2233,16 @@ fn build_v8_user_code(entrypoint: &str, env: &BTreeMap<String, String>) -> Strin
22102233
}
22112234

22122235
fn host_entrypoint_uses_module_mode(entrypoint: &Path) -> bool {
2213-
match entrypoint.extension().and_then(|ext| ext.to_str()) {
2236+
// Follow symlinks before deciding: an `/opt/agentos/bin/<cmd>` command is an
2237+
// extensionless symlink into the package (e.g. → `.../dist/adapter.js`), so
2238+
// the real file's extension AND its nearest `package.json` "type" must be read
2239+
// from the resolved target, not the symlink. Without this, an ESM agent
2240+
// adapter loads as CommonJS and crashes with "Cannot use import statement
2241+
// outside a module".
2242+
let resolved = std::fs::canonicalize(entrypoint).unwrap_or_else(|_| entrypoint.to_path_buf());
2243+
match resolved.extension().and_then(|ext| ext.to_str()) {
22142244
Some("mjs" | "mts") => true,
2215-
Some("js") => nearest_package_json_type(entrypoint).as_deref() == Some("module"),
2245+
Some("js") => nearest_package_json_type(&resolved).as_deref() == Some("module"),
22162246
_ => false,
22172247
}
22182248
}
@@ -3452,16 +3482,20 @@ impl<'a, R: ModuleFsReader> ModuleResolver<'a, R> {
34523482
}
34533483

34543484
let source = self.reader.read_to_string(path)?;
3455-
Some(
3456-
if matches!(
3457-
Path::new(path).extension().and_then(|ext| ext.to_str()),
3458-
Some("js" | "mjs" | "cjs")
3459-
) {
3460-
strip_javascript_hashbang(&source)
3461-
} else {
3462-
source
3463-
},
3464-
)
3485+
// Strip a leading `#!` shebang for JS modules AND for any extensionless
3486+
// executable entrypoint that carries one (e.g. a `/opt/agentos/bin/*`
3487+
// package command named without a `.js` suffix). Node strips shebangs
3488+
// regardless of extension; `strip_javascript_hashbang` is a no-op when the
3489+
// source has none, so this never affects shebang-free files.
3490+
let has_js_extension = matches!(
3491+
Path::new(path).extension().and_then(|ext| ext.to_str()),
3492+
Some("js" | "mjs" | "cjs")
3493+
);
3494+
Some(if has_js_extension || source.starts_with("#!") {
3495+
strip_javascript_hashbang(&source)
3496+
} else {
3497+
source
3498+
})
34653499
}
34663500

34673501
pub fn module_format(&mut self, path: &str) -> Option<LocalResolvedModuleFormat> {
@@ -6030,6 +6064,7 @@ fn builtin_named_exports(module_name: &str) -> &'static [&'static str] {
60306064
"chmodSync",
60316065
"closeSync",
60326066
"constants",
6067+
"copyFileSync",
60336068
"createReadStream",
60346069
"createWriteStream",
60356070
"existsSync",

crates/execution/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ pub use secure_exec_bridge::GuestRuntime;
3434
pub use secure_exec_v8_runtime::execution::GuestModuleReader;
3535
pub use signal::{NodeSignalDispositionAction, NodeSignalHandlerRegistration};
3636
pub use wasm::{
37-
CreateWasmContextRequest, NativeBinaryFormat, StartWasmExecutionRequest, WasmContext,
37+
detect_native_binary_format, CreateWasmContextRequest, NativeBinaryFormat,
38+
StartWasmExecutionRequest, WasmContext,
3839
WasmExecution, WasmExecutionEngine, WasmExecutionError, WasmExecutionEvent,
3940
WasmExecutionLimits, WasmExecutionResult, WasmPermissionTier,
4041
};

crates/execution/src/wasm.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const DEFAULT_WASM_GUEST_HOME: &str = "/root";
5555
const DEFAULT_WASM_GUEST_USER: &str = "root";
5656
const DEFAULT_WASM_GUEST_SHELL: &str = "/bin/sh";
5757
const DEFAULT_WASM_GUEST_PATH: &str =
58-
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
58+
"/usr/local/sbin:/usr/local/bin:/opt/agentos/bin:/usr/sbin:/usr/bin:/sbin:/bin";
5959
// Warmup is a best-effort compile-cache optimization; fall back to a cold start
6060
// instead of burning minutes on a stalled prewarm session.
6161
const DEFAULT_WASM_PREWARM_TIMEOUT_MS: u64 = 30_000;
@@ -5134,7 +5134,7 @@ fn verify_wasm_module_header(
51345134
})
51355135
}
51365136

5137-
fn detect_native_binary_format(header: &[u8]) -> Option<NativeBinaryFormat> {
5137+
pub fn detect_native_binary_format(header: &[u8]) -> Option<NativeBinaryFormat> {
51385138
if header.len() >= 4 && &header[..4] == b"\x7fELF" {
51395139
return Some(NativeBinaryFormat::Elf);
51405140
}

crates/kernel/src/command_registry.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,6 @@ impl CommandRegistry {
8383
.collect()
8484
}
8585

86-
pub fn populate_bin<F>(&self, vfs: &mut F) -> VfsResult<()>
87-
where
88-
F: VirtualFileSystem,
89-
{
90-
self.populate_commands(vfs, self.commands.keys())
91-
}
92-
9386
pub fn populate_driver_bin<F>(&self, vfs: &mut F, driver: &CommandDriver) -> VfsResult<()>
9487
where
9588
F: VirtualFileSystem,

crates/kernel/src/kernel.rs

Lines changed: 28 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3105,19 +3105,8 @@ impl<F: VirtualFileSystem + 'static> KernelVm<F> {
31053105
return Err(KernelError::command_not_found(command));
31063106
};
31073107

3108-
if let Some(registered_command) = self.resolve_registered_command_path(&path) {
3109-
let driver = self
3110-
.commands
3111-
.resolve(&registered_command)
3112-
.cloned()
3113-
.ok_or_else(|| KernelError::command_not_found(&registered_command))?;
3114-
return Ok(ResolvedSpawnCommand {
3115-
command: registered_command,
3116-
args: args.to_vec(),
3117-
driver,
3118-
});
3119-
}
3120-
3108+
// A resolved file is executed by its header (Linux binfmt), not mapped
3109+
// back to a registered command name via hardcoded /bin prefixes.
31213110
let shebang = self
31223111
.parse_shebang_command(&path)?
31233112
.ok_or_else(|| KernelError::new("ENOEXEC", format!("exec format error: {path}")))?;
@@ -3129,7 +3118,24 @@ impl<F: VirtualFileSystem + 'static> KernelVm<F> {
31293118
command: &str,
31303119
cwd: &str,
31313120
) -> KernelResult<Option<String>> {
3121+
// A command with no slash is resolved by a real `$PATH` walk (Linux
3122+
// `execvp`): the first PATH entry holding an executable file wins; a
3123+
// present-but-non-executable match is skipped (so the search may end in
3124+
// ENOENT, not EACCES). An empty PATH element means the cwd. This is what
3125+
// makes `/opt/agentos/bin` packages resolvable by bare name.
31323126
if !command.contains('/') {
3127+
let path_env = self.env.get("PATH").cloned().unwrap_or_default();
3128+
for entry in path_env.split(':') {
3129+
let dir = if entry.is_empty() { cwd } else { entry };
3130+
let candidate = normalize_path(&format!("{dir}/{command}"));
3131+
let Ok(stat) = self.filesystem.stat(&candidate) else {
3132+
continue;
3133+
};
3134+
if stat.is_directory || stat.mode & EXECUTABLE_PERMISSION_BITS == 0 {
3135+
continue;
3136+
}
3137+
return Ok(Some(candidate));
3138+
}
31333139
return Ok(None);
31343140
}
31353141

@@ -3154,29 +3160,6 @@ impl<F: VirtualFileSystem + 'static> KernelVm<F> {
31543160
Ok(Some(path))
31553161
}
31563162

3157-
fn resolve_registered_command_path(&self, path: &str) -> Option<String> {
3158-
let normalized = normalize_path(path);
3159-
for prefix in ["/bin/", "/usr/bin/", "/usr/local/bin/"] {
3160-
let Some(name) = normalized.strip_prefix(prefix) else {
3161-
continue;
3162-
};
3163-
if !name.is_empty() && !name.contains('/') && self.commands.resolve(name).is_some() {
3164-
return Some(name.to_owned());
3165-
}
3166-
}
3167-
3168-
if let Some(name) = normalized
3169-
.strip_prefix("/__secure_exec/commands/")
3170-
.and_then(|suffix| suffix.rsplit('/').next())
3171-
{
3172-
if !name.is_empty() && !name.contains('/') && self.commands.resolve(name).is_some() {
3173-
return Some(name.to_owned());
3174-
}
3175-
}
3176-
3177-
None
3178-
}
3179-
31803163
fn parse_shebang_command(&mut self, path: &str) -> KernelResult<Option<ShebangCommand>> {
31813164
let header = self.filesystem.pread(path, 0, SHEBANG_LINE_MAX_BYTES + 1)?;
31823165
if !header.starts_with(b"#!") {
@@ -3224,10 +3207,17 @@ impl<F: VirtualFileSystem + 'static> KernelVm<F> {
32243207
));
32253208
}
32263209
interpreter_args.remove(0)
3227-
} else if let Some(command) = self.resolve_registered_command_path(&interpreter) {
3228-
command
32293210
} else if self.commands.resolve(&shebang.interpreter).is_some() {
32303211
shebang.interpreter
3212+
} else if let Some(name) = interpreter
3213+
.rsplit('/')
3214+
.next()
3215+
.filter(|name| !name.is_empty() && self.commands.resolve(name).is_some())
3216+
{
3217+
// Resolve the interpreter by its basename (e.g. `/bin/sh` -> `sh`),
3218+
// which replaces the legacy 3-prefix `resolve_registered_command_path`
3219+
// with the Linux-like "the command is the file's basename" rule.
3220+
name.to_owned()
32313221
} else {
32323222
return Err(KernelError::command_not_found(&shebang.interpreter));
32333223
};

crates/kernel/tests/command_registry.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,17 @@ fn records_warning_when_overriding_existing_command() {
7777
}
7878

7979
#[test]
80-
fn populate_bin_creates_stub_entries() {
80+
fn populate_driver_bin_creates_stub_entries() {
8181
let mut vfs = MemoryFileSystem::new();
8282
let mut registry = CommandRegistry::new();
83+
let driver = CommandDriver::new("wasmvm", ["grep", "cat"]);
8384
registry
84-
.register(CommandDriver::new("wasmvm", ["grep", "cat"]))
85+
.register(driver.clone())
8586
.expect("register commands");
8687

87-
registry.populate_bin(&mut vfs).expect("populate /bin");
88+
registry
89+
.populate_driver_bin(&mut vfs, &driver)
90+
.expect("populate /bin");
8891

8992
assert!(vfs.exists("/bin/grep"));
9093
assert!(vfs.exists("/bin/cat"));
@@ -157,7 +160,11 @@ fn kernel_driver_registration_rejects_command_path_names_without_writing_stubs()
157160
}
158161

159162
#[test]
160-
fn mounted_agentos_command_paths_resolve_to_registered_drivers() {
163+
fn path_resolved_files_dispatch_by_header_not_registered_name() {
164+
// The legacy 3-prefix `resolve_registered_command_path` is gone: a file
165+
// resolved by path is executed by its header (binfmt), not mapped back to a
166+
// registered command name. A `#!/bin/sh` stub therefore resolves to the `sh`
167+
// interpreter (by basename) rather than to its own filename.
161168
let mut config = KernelVmConfig::new("vm-mounted-command-path");
162169
config.permissions = Permissions::allow_all();
163170
let mut kernel = KernelVm::new(MemoryFileSystem::new(), config);
@@ -191,6 +198,7 @@ fn mounted_agentos_command_paths_resolve_to_registered_drivers() {
191198
.get(&process.pid())
192199
.cloned()
193200
.expect("process info");
194-
assert_eq!(info.command, "xu");
201+
// Dispatched by the `#!/bin/sh` header → the `sh` interpreter, on the wasmvm driver.
202+
assert_eq!(info.command, "sh");
195203
assert_eq!(info.driver, "wasmvm");
196204
}

0 commit comments

Comments
 (0)