@@ -21,7 +21,7 @@ pub(crate) mod shell;
2121use std:: collections:: HashMap ;
2222
2323use agentos_client:: AgentOs ;
24- use anyhow:: Result ;
24+ use anyhow:: { Context as _ , Result } ;
2525use rivet_actor_plugin_abi as abi;
2626use serde:: de:: DeserializeOwned ;
2727use 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.
8596fn 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`).
118193fn 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}
0 commit comments