Skip to content

Commit c81d201

Browse files
authored
fix(actor-plugin): revive rivetkit $Undefined envelopes in action args (#1644)
rivetkit clients encode JS undefined as the JSON-compat envelope ["$Undefined", 0], so handle.exec(cmd, undefined) and options objects with explicitly-undefined fields failed positional decode with an opaque internal_error. Revive the envelope before serde (array slots become null, undefined object fields are dropped so #[serde(default)] applies), and make action failures log the full anyhow context chain plus a bounded hex prefix of the raw args so encoding mismatches are diagnosable from server logs.
1 parent 40a8500 commit c81d201

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

crates/agentos-actor-plugin/src/actions/mod.rs

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub(crate) mod shell;
2121
use std::collections::HashMap;
2222

2323
use agentos_client::AgentOs;
24-
use anyhow::Result;
24+
use anyhow::{Context as _, Result};
2525
use rivet_actor_plugin_abi as abi;
2626
use serde::de::DeserializeOwned;
2727
use serde::Serialize;
@@ -82,8 +82,83 @@ impl Vars {
8282
}
8383

8484
/// Decode positional CBOR args into `T`.
85+
///
86+
/// The rivetkit client wire wraps values CBOR can't carry in JSON-compat
87+
/// envelopes (`["$Undefined", 0]`, `["$Uint8Array", base64]`, ... — see
88+
/// rivetkit `common/encoding.ts`). JS `undefined` is the one that reaches
89+
/// arbitrary action args (`handle.exec(cmd, undefined)`, options objects
90+
/// with explicitly-undefined fields), so revive it to null before serde;
91+
/// other envelopes keep their existing per-DTO handling.
92+
///
93+
/// Failures carry a bounded hex prefix of the raw payload: action decode
94+
/// errors surface to clients as opaque 500s, so the server-side log must be
95+
/// enough to diagnose an encoding mismatch without a wire capture.
8596
fn decode_as<T: DeserializeOwned>(args: &[u8]) -> Result<T> {
86-
abi::codec::decode_positional(args)
97+
decode_args_impl(args).map_err(|error| {
98+
let prefix_len = args.len().min(96);
99+
let mut hex = String::with_capacity(prefix_len * 2);
100+
for byte in &args[..prefix_len] {
101+
use std::fmt::Write;
102+
let _ = write!(hex, "{byte:02x}");
103+
}
104+
let suffix = if args.len() > prefix_len { "…" } else { "" };
105+
error.context(format!(
106+
"action args cbor ({} bytes): {hex}{suffix}",
107+
args.len()
108+
))
109+
})
110+
}
111+
112+
const JSON_COMPAT_UNDEFINED: &str = "$Undefined";
113+
114+
fn decode_args_impl<T: DeserializeOwned>(args: &[u8]) -> Result<T> {
115+
// Cheap pre-scan: the envelope always contains the literal sentinel text.
116+
let needle = JSON_COMPAT_UNDEFINED.as_bytes();
117+
let has_sentinel = args.windows(needle.len()).any(|w| w == needle);
118+
if !has_sentinel {
119+
return abi::codec::decode_positional(args);
120+
}
121+
let value: ciborium::Value = ciborium::from_reader(std::io::Cursor::new(args))
122+
.context("decode action args from cbor")?;
123+
let value = revive_undefined_envelopes(value);
124+
let mut normalized = Vec::new();
125+
ciborium::into_writer(&value, &mut normalized)
126+
.context("re-encode revived action args")?;
127+
abi::codec::decode_positional(&normalized)
128+
}
129+
130+
/// Revive rivetkit `["$Undefined", 0]` envelopes recursively, matching JS
131+
/// semantics: positional/array occurrences become null, and object fields
132+
/// that are explicitly `undefined` are treated as absent (dropped) so
133+
/// `#[serde(default)]` fields on options DTOs apply their defaults.
134+
fn revive_undefined_envelopes(value: ciborium::Value) -> ciborium::Value {
135+
use ciborium::Value;
136+
match value {
137+
Value::Array(items) => {
138+
if is_undefined_envelope_items(&items) {
139+
return Value::Null;
140+
}
141+
Value::Array(items.into_iter().map(revive_undefined_envelopes).collect())
142+
}
143+
Value::Map(entries) => Value::Map(
144+
entries
145+
.into_iter()
146+
.filter(|(_, v)| !is_undefined_envelope(v))
147+
.map(|(k, v)| (k, revive_undefined_envelopes(v)))
148+
.collect(),
149+
),
150+
Value::Tag(tag, inner) => Value::Tag(tag, Box::new(revive_undefined_envelopes(*inner))),
151+
other => other,
152+
}
153+
}
154+
155+
fn is_undefined_envelope(value: &ciborium::Value) -> bool {
156+
matches!(value, ciborium::Value::Array(items) if is_undefined_envelope_items(items))
157+
}
158+
159+
fn is_undefined_envelope_items(items: &[ciborium::Value]) -> bool {
160+
items.len() == 2
161+
&& matches!(&items[0], ciborium::Value::Text(tag) if tag == JSON_COMPAT_UNDEFINED)
87162
}
88163

89164
/// Reply success: encode `value` with the JSON-compat byte wrapping (byte-exact
@@ -116,7 +191,9 @@ pub(crate) fn encode_event_arg<T: Serialize>(payload: &T) -> Result<Vec<u8>> {
116191

117192
/// Reply failure with the error message (matches `ActionCall::err`).
118193
fn reply_err(host: &HostCtx, token: u64, error: anyhow::Error) {
119-
let message = error.to_string();
194+
// `{:#}` prints the full anyhow context chain — a bare top-level context
195+
// like "decode positional action args" is undiagnosable from client logs.
196+
let message = format!("{error:#}");
120197
host.log_warn(&format!("agent-os action failed: {message}"));
121198
host.reply_err(token, &message);
122199
}

crates/agentos-actor-plugin/tests/action_contract.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,46 @@ fn assert_object_keys(action: &str, value: &CborValue, expected: &[&str]) {
213213
fn normalize_ws(value: &str) -> String {
214214
value.split_whitespace().collect::<Vec<_>>().join(" ")
215215
}
216+
217+
/// Regression: rivetkit clients encode JS `undefined` as the JSON-compat
218+
/// envelope `["$Undefined", 0]` (rivetkit `common/encoding.ts`). Explicitly
219+
/// passing an omitted trailing options arg (`handle.exec(cmd, undefined)`)
220+
/// or an explicitly-undefined options field (`{ env: undefined }`) must
221+
/// decode instead of failing with an opaque positional-decode error.
222+
#[test]
223+
fn undefined_envelopes_decode_as_absent_options() {
224+
let undefined = CborValue::Array(vec![
225+
CborValue::Text(String::from("$Undefined")),
226+
CborValue::Integer(0.into()),
227+
]);
228+
229+
// exec("pwd", undefined)
230+
let args = encode_args(&CborValue::Array(vec![
231+
CborValue::Text(String::from("pwd")),
232+
undefined.clone(),
233+
]));
234+
contract::decode_action_args("exec", &args).expect("exec with trailing undefined options");
235+
236+
// exec("pwd", { env: undefined })
237+
let args = encode_args(&CborValue::Array(vec![
238+
CborValue::Text(String::from("pwd")),
239+
CborValue::Map(vec![(
240+
CborValue::Text(String::from("env")),
241+
undefined.clone(),
242+
)]),
243+
]));
244+
contract::decode_action_args("exec", &args).expect("exec with undefined options field");
245+
246+
// createSession("pi", undefined)
247+
let args = encode_args(&CborValue::Array(vec![
248+
CborValue::Text(String::from("pi")),
249+
undefined,
250+
]));
251+
contract::decode_action_args("createSession", &args).expect("createSession with trailing undefined");
252+
}
253+
254+
fn encode_args(value: &CborValue) -> Vec<u8> {
255+
let mut bytes = Vec::new();
256+
ciborium::into_writer(value, &mut bytes).expect("encode test args");
257+
bytes
258+
}

0 commit comments

Comments
 (0)