feat(sdk): file-injection spawn API + bump contracts to file-injection on main#57
Conversation
…ss file-injection) Bumps the contracts submodule 9742f80 -> 0a50cfc (wit #14 + #15), bringing astrid:[email protected]'s read-only file-injection into the SDK's WIT (build.rs re-stages wit-staging from contracts/host). This closes the lockstep gap with core #890: process-tier capsules built against the SDK now carry the same [email protected] the host exposes, instead of an older shape that fails to link. Adds the Rust builder surface so capsules can actually use it: - InjectionPlacement { EnvPointer(name), FixedPath(path) } - Command::inject_env_file / inject_file_at / inject_file - into_wit maps them onto wit_process::FileInjection. The injected bytes are exposed read-only and unmodifiable by the child or the spawning principal's fs surface; env-pointer is OS-agnostic, fixed-path is Linux-only (rejected on macOS). astrid-contracts.wit (events bundle) is unchanged — file-injection is a host/ change, not an interfaces/ one.
There was a problem hiding this comment.
Code Review
This pull request introduces read-only file injection capabilities to the spawn builder (Command), allowing host-verified, read-only bytes to be exposed to spawned child processes via environment variables or fixed paths. The changes include updating the WIT contract, adding the InjectionPlacement enum and builder methods to the SDK, and adding corresponding unit tests. The review feedback suggests deriving PartialEq and Eq on InjectionPlacement and implementing From for WIT conversion, which simplifies both the conversion logic in into_wit and the assertions in the unit tests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| #[derive(Debug, Clone)] | ||
| pub enum InjectionPlacement { | ||
| /// The host materializes the bytes at a host-owned path (outside every | ||
| /// VFS mount) and sets the named environment variable on the child to | ||
| /// that path. The host owns the path — there is no caller-chosen target. | ||
| /// Works on **Linux and macOS** (the OS-agnostic mode); use it for | ||
| /// programs whose enforced config tier is reachable via an env-redirected | ||
| /// file. The `String` is the env-var name. | ||
| EnvPointer(String), | ||
| /// The host ro-binds the bytes at this absolute in-sandbox path (the | ||
| /// mount point is created, so `path` need not pre-exist). **Linux only** | ||
| /// — rejected on macOS with `invalid-input`, since Seatbelt has no mount | ||
| /// namespace and materializing at a caller-named host path would be an | ||
| /// arbitrary host write. Use it for programs whose enforced tier is a | ||
| /// fixed path with no env redirect. | ||
| FixedPath(String), | ||
| } |
There was a problem hiding this comment.
Deriving PartialEq and Eq on InjectionPlacement makes it much easier for library consumers to compare placement options and write assertions in tests. Additionally, implementing From<InjectionPlacement> for wit_process::InjectionPlacement encapsulates the WIT conversion logic, keeping the builder's into_wit method clean and idiomatic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectionPlacement {
/// The host materializes the bytes at a host-owned path (outside every
/// VFS mount) and sets the named environment variable on the child to
/// that path. The host owns the path — there is no caller-chosen target.
/// Works on **Linux and macOS** (the OS-agnostic mode); use it for
/// programs whose enforced config tier is reachable via an env-redirected
/// file. The `String` is the env-var name.
EnvPointer(String),
/// The host ro-binds the bytes at this absolute in-sandbox path (the
/// mount point is created, so `path` need not pre-exist). **Linux only**
/// — rejected on macOS with `invalid-input`, since Seatbelt has no mount
/// namespace and materializing at a caller-named host path would be an
/// arbitrary host write. Use it for programs whose enforced tier is a
/// fixed path with no env redirect.
FixedPath(String),
}
impl From<InjectionPlacement> for wit_process::InjectionPlacement {
fn from(placement: InjectionPlacement) -> Self {
match placement {
InjectionPlacement::EnvPointer(v) => Self::EnvPointer(v),
InjectionPlacement::FixedPath(p) => Self::FixedPath(p),
}
}
}References
- To improve maintainability and reduce duplication, refactor repeated blocks of code into a single helper function.
| file_injections: self | ||
| .file_injections | ||
| .into_iter() | ||
| .map(|(content, placement)| wit_process::FileInjection { | ||
| content, | ||
| placement: match placement { | ||
| InjectionPlacement::EnvPointer(v) => { | ||
| wit_process::InjectionPlacement::EnvPointer(v) | ||
| }, | ||
| InjectionPlacement::FixedPath(p) => { | ||
| wit_process::InjectionPlacement::FixedPath(p) | ||
| }, | ||
| }, | ||
| }) | ||
| .collect(), |
There was a problem hiding this comment.
With the From implementation in place, we can simplify the file_injections mapping in into_wit by calling placement.into(), which improves readability and maintainability.
file_injections: self
.file_injections
.into_iter
.map(|(content, placement)| wit_process::FileInjection {
content,
placement: placement.into(),
})
.collect(),References
- To improve maintainability and reduce duplication, refactor repeated blocks of code into a single helper function.
| assert!(matches!( | ||
| cmd.file_injections[0].1, | ||
| InjectionPlacement::EnvPointer(ref v) if v == "CLAUDE_CODE_MANAGED_SETTINGS_PATH" | ||
| )); | ||
| assert_eq!(cmd.file_injections[0].0, b"{}"); | ||
| assert!(matches!( | ||
| cmd.file_injections[1].1, | ||
| InjectionPlacement::FixedPath(ref p) if p == "/etc/codex/requirements.toml" | ||
| )); | ||
| assert!(matches!( | ||
| cmd.file_injections[2].1, | ||
| InjectionPlacement::EnvPointer(ref v) if v == "GEMINI" | ||
| )); |
There was a problem hiding this comment.
Since InjectionPlacement now implements PartialEq and Eq, we can replace the verbose matches! assertions with cleaner and more readable assert_eq! calls.
| assert!(matches!( | |
| cmd.file_injections[0].1, | |
| InjectionPlacement::EnvPointer(ref v) if v == "CLAUDE_CODE_MANAGED_SETTINGS_PATH" | |
| )); | |
| assert_eq!(cmd.file_injections[0].0, b"{}"); | |
| assert!(matches!( | |
| cmd.file_injections[1].1, | |
| InjectionPlacement::FixedPath(ref p) if p == "/etc/codex/requirements.toml" | |
| )); | |
| assert!(matches!( | |
| cmd.file_injections[2].1, | |
| InjectionPlacement::EnvPointer(ref v) if v == "GEMINI" | |
| )); | |
| assert_eq!( | |
| cmd.file_injections[0].1, | |
| InjectionPlacement::EnvPointer("CLAUDE_CODE_MANAGED_SETTINGS_PATH".to_string()) | |
| ); | |
| assert_eq!(cmd.file_injections[0].0, b"{}"); | |
| assert_eq!( | |
| cmd.file_injections[1].1, | |
| InjectionPlacement::FixedPath("/etc/codex/requirements.toml".to_string()) | |
| ); | |
| assert_eq!( | |
| cmd.file_injections[2].1, | |
| InjectionPlacement::EnvPointer("GEMINI".to_string()) | |
| ); |
## Linked Issue Closes #919 ## Summary Repins the `wit` submodule from `b8fdb6f` (the tip of the now-merged wit PR #15 branch `feat/process-file-injection`) onto the canonical wit main commit `0a50cfc` (#15 squash-merged). The tree is identical to `b8fdb6f`, so there is no WIT content change — this only moves the pin off an unmerged-PR commit onto main. ## Changes - Bump `wit` submodule `b8fdb6f` → `0a50cfc` (tree-identical; pure pointer move). ## Verification - `git diff b8fdb6f 0a50cfc` in the wit repo is empty (identical tree), so there is no host/guest WIT behaviour change — the staged diff is a single gitlink change. - Lockstep with `astrid-runtime/sdk-rust#57`, which bumps the contracts submodule to the same `0a50cfc`. ## Checklist - [x] Linked to an issue - [x] CHANGELOG.md updated (not applicable — submodule pointer move, tree-identical, no code or behaviour change)
Bumps the
contractssubmodule9742f80→0a50cfc(canonical witmain: #14 persistent-stdin un-stub + #15 process file-injection) and adds the Rust builder surface for read-only file injection.Why
core #890 landed read-only file injection on the host +
astrid:[email protected], and the wit side is now merged (#15). The SDK was pinned at wit #13, so a process-tier capsule built against it carried an older[email protected]than the host exposes — the linker mismatch the submodule lockstep exists to prevent. Bumping the pin (andbuild.rsre-stagingwit-stagingfromcontracts/host) restores lockstep.API
InjectionPlacement { EnvPointer(String), FixedPath(String) }Command::inject_env_file(env_var, content)— OS-agnostic (host-owned path + env redirect)Command::inject_file_at(path, content)— Linux-only ro-bind (rejected on macOS)Command::inject_file(content, placement)— explicit placementinto_witmaps towit_process::FileInjection. Honored by all spawn modes.Injected bytes are read-only and unmodifiable by the child, its subprocesses, or the spawning principal's
fssurface.Verification
scripts/sync-contracts-wit.sh --checkpasses (events-bundle mirror in sync —astrid-contracts.witis unchanged since file-injection is ahost/contract, notinterfaces/).inject_builders_accumulate_in_order,no_injection_by_default.Lockstep with core #920 (wit submodule bump to the same
0a50cfc).