diff --git a/Console/README.md b/Console/README.md
index b670fe3..dd3bdb9 100644
--- a/Console/README.md
+++ b/Console/README.md
@@ -20,20 +20,49 @@ it is otherwise a faithful decompilation, not a rewrite.
## Dependencies
-The console references five assemblies, vendored as binaries under `lib/`
-(copied from `assets/Tesla Console/`):
+The console references these assemblies. Most are vendored as binaries under
+`lib/` (copied from `assets/Tesla Console/`):
| Assembly | Origin |
|----------|--------|
| `WeifenLuo.WinFormsUI.Docking.dll` | Third-party docking UI (open source, see `assets/Tesla Console/WeifenLuo.txt`) |
-| `TeslaConsoleLaunchLib.dll` | Wire types / launch protocol (proprietary) |
-| `TeslaSecureConfiguration.dll` | First-boot secure config protocol (proprietary) |
-| `Munga Net.dll` | Networking helpers (proprietary) |
-| `BitmapLibrary.dll` | Plasma-display bitmap rendering (proprietary) |
+| `TeslaConsoleLaunchLib.dll` | Wire types / launch protocol — **now built from source** (see below) |
+| `TeslaSecureConfiguration.dll` | First-boot secure config protocol — **now built from source** (see below) |
+| `Munga Net.dll` | Managed C# client for the Red Planet game's Munga protocol (TCP 1501); manual binary serialization, no BinaryFormatter (proprietary, vendored) |
+| `BitmapLibrary.dll` | Plasma-display bitmap rendering (proprietary, vendored) |
-These are still referenced as compiled binaries. They are also .NET 2.0 managed
-assemblies and could be decompiled to source later if full-source builds are
-needed.
+> The Red Planet game itself is C++ and lives in its own repo (`c:\vwe\rp411` /
+> `gitea.mysticmachines.com/VWE/RP411.git`). That source defines the Munga protocol
+> but is **not** a drop-in for the managed `Munga Net.dll` above, so it is not
+> vendored here.
+
+Two of these are no longer vendored binaries — they are built from source and
+shared across the suite:
+
+- `TeslaConsoleLaunchLib` ← `../Contract/Tesla.Contract.csproj`, a multi-targeted
+ `net48;net8.0-windows` project: the single source of truth for the Console↔Launcher
+ RPC contract (wire types, the `PodManagerConnection` client, and the framed-JSON
+ `PodRpc` protocol), shared with the Launcher Service. The assembly keeps the
+ `TeslaConsoleLaunchLib` name so the original-exe baseline still resolves in the
+ differential tests; the wire no longer embeds assembly names (see RPC note below).
+- `TeslaSecureConfiguration` ← `../SecureConfig/Tesla.SecureConfig.csproj` (net48),
+ the first-boot provisioning protocol (UDP beacons, OFB crypto, RSA key exchange).
+
+The original `TeslaSecureConfiguration.dll` is retained under `lib/` as the baseline
+for the byte-identical crypto guard (`SecureConfigCompatTests`). The remaining
+vendored assemblies (`Munga Net`, `BitmapLibrary`) are .NET 2.0 managed and could be
+decompiled to source the same way if full-source builds are needed.
+
+### Console ↔ Launcher RPC (no BinaryFormatter)
+
+The pod-management channel (TCP 53290) runs **length-prefixed System.Text.Json**
+frames over the existing OFB-encrypted stream — see `Contract/PodRpcProtocol.cs`,
+shared verbatim by both ends. This replaced the original `BinaryFormatter` +
+serialized-`MethodBase` scheme (a remote-code-execution sink and the reason the
+Launcher was pinned to EOL .NET 6); dispatch is now by method-name string. The
+Launcher Service/Agent target **net8**. Note the Console still uses `BinaryFormatter`
+for *local* disk persistence (`Site` config, mission results) — that is local file
+I/O on net48, not the network surface, and is intentionally left alone.
## Layout
diff --git a/Console/TeslaConsole.csproj b/Console/TeslaConsole.csproj
index 1ea7af3..9529057 100644
--- a/Console/TeslaConsole.csproj
+++ b/Console/TeslaConsole.csproj
@@ -53,17 +53,21 @@
lib\WeifenLuo.WinFormsUI.Docking.dll
-
- lib\TeslaSecureConfiguration.dll
-
lib\Munga Net.dll
-
- lib\TeslaConsoleLaunchLib.dll
-
lib\BitmapLibrary.dll
+
+
+
+
+
+
+
diff --git a/Console/tests/TeslaConsole.DiffTests/PodRpcProtocolTests.cs b/Console/tests/TeslaConsole.DiffTests/PodRpcProtocolTests.cs
new file mode 100644
index 0000000..a324b34
--- /dev/null
+++ b/Console/tests/TeslaConsole.DiffTests/PodRpcProtocolTests.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+using Tesla.Net;
+using Xunit;
+
+namespace TeslaConsole.DiffTests
+{
+ ///
+ /// Exercises the framed JSON RPC protocol (PodRpc) that replaced the OFB +
+ /// BinaryFormatter wire. Both the Console client (PodManagerConnection) and the
+ /// Launcher Service share this exact code, so an in-process round-trip
+ /// (encode request → decode → encode response → decode) validates the wire
+ /// contract both ends depend on. The live console↔pod path still needs a pod;
+ /// this covers the serialization, which is the part that can silently break.
+ ///
+ public class PodRpcProtocolTests
+ {
+ private static readonly Guid Key = new Guid("7D241B1F-AB6D-4e08-9C20-12294E743D94");
+
+ [Fact]
+ public void Request_RoundTrips_Method_And_Args()
+ {
+ var ms = new MemoryStream();
+ PodRpc.WriteRequest(ms, "KillApp", new object[] { Key, 4242 });
+ ms.Position = 0;
+
+ var req = PodRpc.ReadRequest(ms);
+ Assert.Equal("KillApp", req.Method);
+ Assert.Equal(2, req.Args.Count);
+ Assert.Equal(Key, req.Args[0].GetGuid());
+ Assert.Equal(4242, req.Args[1].GetInt32());
+ }
+
+ [Fact]
+ public void Request_With_No_Args_RoundTrips()
+ {
+ var ms = new MemoryStream();
+ PodRpc.WriteRequest(ms, "FullUpdate", null);
+ ms.Position = 0;
+
+ var req = PodRpc.ReadRequest(ms);
+ Assert.Equal("FullUpdate", req.Method);
+ Assert.Empty(req.Args);
+ }
+
+ [Fact]
+ public void Two_Frames_Read_Back_To_Back()
+ {
+ var ms = new MemoryStream();
+ PodRpc.WriteRequest(ms, "Ping", new object[] { new DateTime(2026, 6, 30, 12, 0, 0, DateTimeKind.Utc) });
+ PodRpc.WriteRequest(ms, "KillAllApps", null);
+ ms.Position = 0;
+
+ Assert.Equal("Ping", PodRpc.ReadRequest(ms).Method);
+ Assert.Equal("KillAllApps", PodRpc.ReadRequest(ms).Method);
+ }
+
+ [Fact]
+ public void Response_Error_RoundTrips()
+ {
+ var ms = new MemoryStream();
+ PodRpc.WriteResponse(ms, null, "boom on the pod");
+ ms.Position = 0;
+
+ var resp = PodRpc.ReadResponse(ms);
+ Assert.Equal("boom on the pod", resp.Error);
+ }
+
+ [Fact]
+ public void Response_Primitive_Results_RoundTrip()
+ {
+ Assert.Equal(99, RoundTripResult(99));
+ Assert.Equal(Key, RoundTripResult(Key));
+ Assert.Equal(0.5f, RoundTripResult(0.5f));
+ Assert.Equal(new DateTime(2026, 6, 30), RoundTripResult(new DateTime(2026, 6, 30)));
+ }
+
+ [Fact]
+ public void Response_LaunchData_Array_RoundTrips_With_Fields()
+ {
+ var apps = new[]
+ {
+ new LaunchData
+ {
+ LaunchPair = new LaunchPair { LaunchKey = Key, DisplayName = "Red Planet" },
+ WorkingDirectory = @"C:\Games\RedPlanet",
+ ExeFile = "RedPlanet.exe",
+ Arguments = "-res 1024 768",
+ AutoRestart = true,
+ },
+ };
+
+ var got = RoundTripResult(apps);
+ Assert.Single(got);
+ Assert.Equal(Key, got[0].LaunchPair.LaunchKey);
+ Assert.Equal("Red Planet", got[0].LaunchPair.DisplayName);
+ Assert.Equal(@"C:\Games\RedPlanet", got[0].WorkingDirectory);
+ Assert.Equal("RedPlanet.exe", got[0].ExeFile);
+ Assert.Equal("-res 1024 768", got[0].Arguments);
+ Assert.True(got[0].AutoRestart);
+ }
+
+ [Fact]
+ public void Response_FullUpdateData_RoundTrips()
+ {
+ var full = new FullUpdateData
+ {
+ InstalledApps = new[]
+ {
+ new LaunchData { LaunchPair = new LaunchPair { LaunchKey = Key, DisplayName = "RP" } },
+ },
+ LaunchedApps = new[] { new LaunchedAppData { LaunchKey = Key, ProcessId = 1234 } },
+ VolumeLevel = 0.75f,
+ };
+
+ var got = RoundTripResult(full);
+ Assert.Equal(0.75f, got.VolumeLevel);
+ Assert.Equal("RP", got.InstalledApps[0].LaunchPair.DisplayName);
+ Assert.Equal(1234, got.LaunchedApps[0].ProcessId);
+ Assert.Equal(Key, got.LaunchedApps[0].LaunchKey);
+ }
+
+ [Fact]
+ public void Response_OutOfBandProgress_RoundTrips()
+ {
+ var p = new OutOfBandProgress { PercentComplete = 73, Status = "Extracting...", IsCompleted = false };
+ var got = RoundTripResult(p);
+ Assert.Equal(73, got.PercentComplete);
+ Assert.Equal("Extracting...", got.Status);
+ Assert.False(got.IsCompleted);
+ }
+
+ [Fact]
+ public void Oversized_Frame_Length_Is_Rejected()
+ {
+ var ms = new MemoryStream();
+ // Craft a frame header claiming more than MaxFrameBytes.
+ ms.Write(BitConverter.GetBytes(PodRpc.MaxFrameBytes + 1), 0, 4);
+ ms.Position = 0;
+ Assert.Throws(() => PodRpc.ReadFrame(ms));
+ }
+
+ // Serialize a result the way the Launcher does, read it the way the Console does.
+ private static T RoundTripResult(object result)
+ {
+ var ms = new MemoryStream();
+ PodRpc.WriteResponse(ms, result, null);
+ ms.Position = 0;
+ var resp = PodRpc.ReadResponse(ms);
+ Assert.Null(resp.Error);
+ return resp.Result.Deserialize(PodRpc.JsonOptions);
+ }
+ }
+}
diff --git a/Console/tests/TeslaConsole.DiffTests/SecureConfigCompatTests.cs b/Console/tests/TeslaConsole.DiffTests/SecureConfigCompatTests.cs
new file mode 100644
index 0000000..54fab53
--- /dev/null
+++ b/Console/tests/TeslaConsole.DiffTests/SecureConfigCompatTests.cs
@@ -0,0 +1,147 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Reflection;
+using System.Security.Cryptography;
+using Xunit;
+
+namespace TeslaConsole.DiffTests
+{
+ ///
+ /// Proves the source-built secure-config library (SecureConfig/Tesla.SecureConfig,
+ /// emitting assembly TeslaSecureConfiguration) is behaviourally identical to the
+ /// original vendored TeslaSecureConfiguration.dll for the parts that define the
+ /// over-the-wire provisioning format: the OFB keystream cipher and the
+ /// BasicConfigResponse byte layout. Byte-identity against the original binary
+ /// transitively guarantees compatibility with the pod-side counterpart (the
+ /// Launcher's hand-written OFBDuplexStream), which was built against that binary.
+ ///
+ /// The original is loaded out-of-band with Assembly.LoadFile (it shares identity
+ /// with the source build); construction logic is identical reflection over both.
+ ///
+ public class SecureConfigCompatTests
+ {
+ private static readonly Assembly SourceCfg = typeof(Tesla.BasicConfigResponse).Assembly;
+ private static readonly Assembly VendoredCfg = LoadVendored();
+
+ private static Assembly LoadVendored()
+ {
+ string path = Path.Combine(AssemblyPaths.RepoRoot, "lib", "TeslaSecureConfiguration.dll");
+ Assert.True(File.Exists(path), "Vendored baseline not found: " + path);
+ return Assembly.LoadFile(path);
+ }
+
+ // Fixed, deterministic inputs so both assemblies see identical material.
+ private static readonly byte[] Key = BuildBytes(32, i => (byte)(i * 7 + 1));
+ private static readonly byte[] Iv = BuildBytes(16, i => (byte)(i * 13 + 5));
+ private static readonly byte[] Plaintext =
+ System.Text.Encoding.UTF8.GetBytes("The quick brown pod jumps over the lazy console. 0123456789!");
+
+ [Fact]
+ public void Baseline_And_Source_Are_Distinct_Assemblies_With_Same_Identity()
+ {
+ Assert.NotEqual(SourceCfg.Location, VendoredCfg.Location);
+ Assert.Equal(VendoredCfg.GetName().Name, SourceCfg.GetName().Name);
+ Assert.Equal(VendoredCfg.GetName().Version, SourceCfg.GetName().Version);
+ }
+
+ [Fact]
+ public void OFBCryptoStream_Produces_Identical_Ciphertext()
+ {
+ byte[] fromVendored = Encrypt(VendoredCfg);
+ byte[] fromSource = Encrypt(SourceCfg);
+ Assert.Equal(fromVendored, fromSource);
+ // Sanity: it actually encrypted (output differs from plaintext).
+ Assert.NotEqual(Plaintext, fromSource);
+ }
+
+ [Fact]
+ public void OFBCryptoStream_RoundTrips()
+ {
+ byte[] cipher = Encrypt(SourceCfg);
+ byte[] plain = Decrypt(SourceCfg, cipher);
+ Assert.Equal(Plaintext, plain);
+ }
+
+ [Fact]
+ public void BasicConfigResponse_ToBytes_Is_Identical()
+ {
+ Assert.Equal(ConfigToBytes(VendoredCfg), ConfigToBytes(SourceCfg));
+ }
+
+ [Fact]
+ public void BasicConfigResponse_RoundTrips_Across_Implementations()
+ {
+ // Bytes produced by the vendored impl must parse correctly in the source impl.
+ byte[] bytes = ConfigToBytes(VendoredCfg);
+ object parsed = Activator.CreateInstance(
+ SourceCfg.GetType("Tesla.BasicConfigResponse", true), bytes);
+ Assert.Equal("192.168.1.50", Prop(parsed, "Address").ToString());
+ Assert.Equal("255.255.255.0", Prop(parsed, "Mask").ToString());
+ Assert.Equal("192.168.1.1", Prop(parsed, "Gateway").ToString());
+ Assert.Equal("8.8.8.8", Prop(parsed, "Dns").ToString());
+ Assert.Equal("POD-01", Prop(parsed, "HostName"));
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────
+
+ private static byte[] Encrypt(Assembly asm)
+ {
+ using var aes = Rijndael.Create();
+ aes.Mode = CipherMode.ECB;
+ aes.Key = Key;
+ var ms = new MemoryStream();
+ var ofb = NewOfb(asm, ms, aes.CreateEncryptor(), CryptoStreamMode.Write, Iv);
+ ofb.Write(Plaintext, 0, Plaintext.Length);
+ ofb.Flush();
+ return ms.ToArray();
+ }
+
+ private static byte[] Decrypt(Assembly asm, byte[] cipher)
+ {
+ using var aes = Rijndael.Create();
+ aes.Mode = CipherMode.ECB;
+ aes.Key = Key;
+ var ms = new MemoryStream(cipher);
+ var ofb = NewOfb(asm, ms, aes.CreateEncryptor(), CryptoStreamMode.Read, Iv);
+ var outBuf = new byte[Plaintext.Length];
+ int off = 0;
+ while (off < outBuf.Length)
+ {
+ int n = ofb.Read(outBuf, off, outBuf.Length - off);
+ if (n == 0) break;
+ off += n;
+ }
+ return outBuf;
+ }
+
+ private static Stream NewOfb(Assembly asm, Stream inner, ICryptoTransform t, CryptoStreamMode mode, byte[] iv)
+ // OFBCryptoStream keeps the IV array by reference and reuses it as its
+ // ping-pong keystream scratch buffer, so it MUST get its own copy — the
+ // real callers always pass a freshly generated IV.
+ => (Stream)Activator.CreateInstance(
+ asm.GetType("System.Security.Cryptography.OFBCryptoStream", true),
+ inner, t, mode, (byte[])iv.Clone());
+
+ private static byte[] ConfigToBytes(Assembly asm)
+ {
+ Type t = asm.GetType("Tesla.BasicConfigResponse", true);
+ object cfg = Activator.CreateInstance(t,
+ IPAddress.Parse("192.168.1.50"),
+ IPAddress.Parse("255.255.255.0"),
+ IPAddress.Parse("192.168.1.1"),
+ IPAddress.Parse("8.8.8.8"),
+ "POD-01");
+ return (byte[])t.GetMethod("ToBytes").Invoke(cfg, null);
+ }
+
+ private static object Prop(object o, string name) => o.GetType().GetProperty(name).GetValue(o, null);
+
+ private static byte[] BuildBytes(int n, Func f)
+ {
+ var b = new byte[n];
+ for (int i = 0; i < n; i++) b[i] = f(i);
+ return b;
+ }
+ }
+}
diff --git a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
index 0fca65f..052a2fb 100644
--- a/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
+++ b/Console/tests/TeslaConsole.DiffTests/TeslaConsole.DiffTests.csproj
@@ -41,6 +41,13 @@
false
false
+
+
+
+
diff --git a/Contract/Client/MockPodManagerConnection.cs b/Contract/Client/MockPodManagerConnection.cs
new file mode 100644
index 0000000..64f9109
--- /dev/null
+++ b/Contract/Client/MockPodManagerConnection.cs
@@ -0,0 +1,177 @@
+// =============================================================================
+// Tesla.Contract — Mock RPC client (net48 only)
+// =============================================================================
+// Reconstructed verbatim from TeslaConsoleLaunchLib.dll. An offline test/dev
+// stand-in for PodManagerConnection. Not referenced by the shipping Console UI,
+// but retained for parity with the original assembly surface.
+// =============================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Threading;
+
+namespace Tesla.Net
+{
+ public class MockPodManagerConnection : IPodManagerConnection, ILauncherService
+ {
+ public static readonly Guid RPAppGuid = new Guid("7D241B1F-AB6D-4e08-9C20-12294E743D94");
+
+ public static readonly Guid RPMRAppGuid = new Guid("8F71D5C2-38E4-413c-8E22-88CAD08774D2");
+
+ public static readonly Guid RPLCAppGuid = new Guid("57A0B3C2-D5CF-46d6-ABED-A8F4A26AB086");
+
+ public static readonly Guid MW4AppGuid = new Guid("CC8500ED-A653-45a7-BEF8-C332D30371A6");
+
+ public static readonly Guid MW4LCAppGuid = new Guid("8EE93A6C-F16A-49be-B867-37FAE9087FFF");
+
+ private bool mIsOpen;
+
+ private List mLaunchedApps = new List();
+
+ public bool IsOpen => mIsOpen;
+
+ public float VolumeLevel
+ {
+ get
+ {
+ throw new NotImplementedException();
+ }
+ set
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ public void Open(IPEndPoint remoteEndPoint, byte[] secureKey)
+ {
+ Thread.Sleep(5000);
+ mIsOpen = true;
+ }
+
+ public void Close()
+ {
+ Thread.Sleep(5000);
+ mIsOpen = false;
+ }
+
+ public Guid InstallProduct(string filename)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void ClearStore()
+ {
+ throw new NotImplementedException();
+ }
+
+ public DateTime Ping(DateTime now)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Guid InitiateInstallProduct()
+ {
+ throw new NotImplementedException();
+ }
+
+ public OutOfBandProgress GetOutOfBandProgress(Guid invokeCallId)
+ {
+ throw new NotImplementedException();
+ }
+
+ public int LaunchApp(Guid launchKey)
+ {
+ throw new NotImplementedException();
+ }
+
+ public LaunchPair[] GetLaunchableApps()
+ {
+ return new LaunchPair[2]
+ {
+ new LaunchPair
+ {
+ DisplayName = "BattleTech Firestorm",
+ LaunchKey = MW4AppGuid
+ },
+ new LaunchPair
+ {
+ DisplayName = "Red Planet",
+ LaunchKey = RPAppGuid
+ }
+ };
+ }
+
+ public void KillApp(Guid launchKey, int processId)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void KillAllOfType(Guid launchKey)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void KillAllApps()
+ {
+ throw new NotImplementedException();
+ }
+
+ public void Shutdown(bool restart)
+ {
+ throw new NotImplementedException();
+ }
+
+ public LaunchedAppData[] GetLaunchedApps()
+ {
+ return mLaunchedApps.ToArray();
+ }
+
+ public LaunchData[] GetInstalledApps()
+ {
+ return new LaunchData[2]
+ {
+ new LaunchData
+ {
+ LaunchPair = new LaunchPair
+ {
+ DisplayName = "BattleTech Firestorm",
+ LaunchKey = MW4AppGuid
+ }
+ },
+ new LaunchData
+ {
+ LaunchPair = new LaunchPair
+ {
+ DisplayName = "Red Planet",
+ LaunchKey = RPAppGuid
+ }
+ }
+ };
+ }
+
+ public void RemoveApp(Guid index)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void InstallApp(LaunchData data)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void UninstallApp(Guid launchKey)
+ {
+ throw new NotImplementedException();
+ }
+
+ public FullUpdateData FullUpdate()
+ {
+ FullUpdateData result = default(FullUpdateData);
+ result.InstalledApps = GetInstalledApps();
+ result.LaunchedApps = GetLaunchedApps();
+ result.VolumeLevel = VolumeLevel;
+ return result;
+ }
+ }
+}
diff --git a/Contract/Client/PodManagerConnection.cs b/Contract/Client/PodManagerConnection.cs
new file mode 100644
index 0000000..8933dd0
--- /dev/null
+++ b/Contract/Client/PodManagerConnection.cs
@@ -0,0 +1,276 @@
+// =============================================================================
+// Tesla.Contract — Console-side RPC client (net48 only)
+// =============================================================================
+// Opens an OFB-encrypted TCP connection to the pod (port 53290) and dispatches
+// ILauncherService calls as framed JSON RpcRequest / RpcResponse pairs (see
+// PodRpcProtocol.cs). Replaces the original serialized-MethodBase + BinaryFormatter
+// scheme; the OFB transport (PodConfigurationServer.NegotiateCryptoStreams) is
+// unchanged.
+//
+// Depends on Tesla.PodConfigurationServer (Tesla.SecureConfig) for the crypto
+// handshake, so it is compiled for net48 only. The Launcher is the server end.
+// =============================================================================
+
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+
+namespace Tesla.Net
+{
+ public class PodManagerConnection : IPodManagerConnection, ILauncherService, IDisposable
+ {
+ private TcpClient mOpenClient;
+
+ private object mCommSyncRoot = new object();
+
+ protected Stream mUnderlyingOutStream;
+
+ private BufferedStream mReader;
+
+ private BufferedStream mWriter;
+
+ private IPEndPoint mEndPoint;
+
+ private byte[] mKey;
+
+ public bool IsOpen
+ {
+ get
+ {
+ if (mOpenClient == null || mOpenClient.Client == null || mWriter == null || mReader == null)
+ {
+ return false;
+ }
+ return mOpenClient.Connected;
+ }
+ }
+
+ protected object SyncRoot => mCommSyncRoot;
+
+ public float VolumeLevel
+ {
+ get => Invoke("get_VolumeLevel");
+ set => InvokeVoid("set_VolumeLevel", value);
+ }
+
+ public void Open(IPEndPoint remoteEndPoint, byte[] secureKey)
+ {
+ mEndPoint = remoteEndPoint;
+ mKey = secureKey;
+ mOpenClient = new TcpClient();
+ mOpenClient.SendTimeout = 10000;
+ mOpenClient.ReceiveTimeout = 10000;
+ mOpenClient.Connect(remoteEndPoint);
+ if (!PodConfigurationServer.NegotiateCryptoStreams(mOpenClient.GetStream(), secureKey, out var outStream, out var inStream))
+ {
+ mOpenClient.Close();
+ throw new IOException("Error connecting to pod.");
+ }
+ mUnderlyingOutStream = outStream;
+ mWriter = new BufferedStream(outStream);
+ mReader = new BufferedStream(inStream);
+ }
+
+ public void Close()
+ {
+ try
+ {
+ mWriter.Close();
+ mReader.Close();
+ mOpenClient.Client.Close();
+ mOpenClient = null;
+ }
+ catch
+ {
+ }
+ }
+
+ public Guid InstallProduct(string filepath)
+ {
+ if (!File.Exists(filepath))
+ {
+ return Guid.Empty;
+ }
+ FileStream outOfBandData = File.OpenRead(filepath);
+ Guid result = Guid.Empty;
+ try
+ {
+ result = OutOfBandInvocation.Invoke(mEndPoint, mKey, "InitiateInstallProduct", new object[0], outOfBandData);
+ return result;
+ }
+ catch (IOException)
+ {
+ return result;
+ }
+ }
+
+ // ── RPC core ─────────────────────────────────────────────────────────
+ // Writes a framed JSON request and (optionally) reads the framed response,
+ // deserializing its Result into the caller's expected type.
+
+ protected object InvokeCore(string method, bool waitForResponse, Type resultType, object[] parameters)
+ {
+ try
+ {
+ lock (mCommSyncRoot)
+ {
+ PodRpc.WriteRequest(mWriter, method, parameters);
+ if (!waitForResponse)
+ {
+ return null;
+ }
+ RpcResponse response = PodRpc.ReadResponse(mReader);
+ if (response.Error != null)
+ {
+ throw new Exception("Server function threw an exception: " + response.Error);
+ }
+ if (resultType == null
+ || response.Result.ValueKind == JsonValueKind.Null
+ || response.Result.ValueKind == JsonValueKind.Undefined)
+ {
+ return resultType != null && resultType.IsValueType
+ ? Activator.CreateInstance(resultType)
+ : null;
+ }
+ return response.Result.Deserialize(resultType, PodRpc.JsonOptions);
+ }
+ }
+ catch (IOException innerException)
+ {
+ throw new TeslaConsole.ConnectionLostException("Connection Lost", innerException);
+ }
+ }
+
+ protected T Invoke(string method, params object[] parameters)
+ => (T)InvokeCore(method, waitForResponse: true, typeof(T), parameters);
+
+ protected void InvokeVoid(string method, params object[] parameters)
+ => InvokeCore(method, waitForResponse: true, null, parameters);
+
+ protected void InvokeOneWay(string method, params object[] parameters)
+ => InvokeCore(method, waitForResponse: false, null, parameters);
+
+ // ── ILauncherService ─────────────────────────────────────────────────
+
+ public void ClearStore() => InvokeOneWay("ClearStore");
+
+ public DateTime Ping(DateTime now) => Invoke("Ping", now);
+
+ public int LaunchApp(Guid launchKey) => Invoke("LaunchApp", launchKey);
+
+ public LaunchPair[] GetLaunchableApps() => Invoke("GetLaunchableApps");
+
+ public Guid InitiateInstallProduct() => Invoke("InitiateInstallProduct");
+
+ public OutOfBandProgress GetOutOfBandProgress(Guid installId)
+ => Invoke("GetOutOfBandProgress", installId);
+
+ public void KillApp(Guid launchKey, int processId)
+ => InvokeVoid("KillApp", launchKey, processId);
+
+ public void KillAllOfType(Guid launchKey) => InvokeVoid("KillAllOfType", launchKey);
+
+ public void KillAllApps() => InvokeVoid("KillAllApps");
+
+ public void Shutdown(bool doRestart) => InvokeVoid("Shutdown", doRestart);
+
+ public LaunchedAppData[] GetLaunchedApps() => Invoke("GetLaunchedApps");
+
+ public LaunchData[] GetInstalledApps() => Invoke("GetInstalledApps");
+
+ public void RemoveApp(Guid index) => InvokeVoid("RemoveApp", index);
+
+ public void InstallApp(LaunchData data) => InvokeVoid("InstallApp", data);
+
+ public void UninstallApp(Guid launchKey) => InvokeVoid("UninstallApp", launchKey);
+
+ public FullUpdateData FullUpdate() => Invoke("FullUpdate");
+
+ public void Dispose()
+ {
+ Close();
+ }
+ }
+
+ public class OutOfBandInvocation : PodManagerConnection
+ {
+ private Stream mOutOfBandData;
+
+ public static Guid Invoke(IPEndPoint target, byte[] key, string methodName, object[] parameters, Stream outOfBandData)
+ {
+ return Invoke(target, key, methodName, parameters, outOfBandData, null);
+ }
+
+ public static Guid Invoke(IPEndPoint target, byte[] key, MethodBase method, object[] parameters, Stream outofBandData)
+ {
+ return Invoke(target, key, method.Name, parameters, outofBandData, null);
+ }
+
+ public static Guid Invoke(IPEndPoint target, byte[] key, MethodBase method, object[] parameters, Stream outOfBandData, OutOfBandProgressChanged progressCallback)
+ {
+ return Invoke(target, key, method.Name, parameters, outOfBandData, progressCallback);
+ }
+
+ public static Guid Invoke(IPEndPoint target, byte[] key, string methodName, object[] parameters, Stream outOfBandData, OutOfBandProgressChanged progressCallback)
+ {
+ OutOfBandInvocation outOfBandInvocation = new OutOfBandInvocation(target, key);
+ // progressCallback is vestigial: install progress is polled via
+ // GetOutOfBandProgress, never pushed. Kept on the signature for surface parity.
+ _ = progressCallback;
+ if (outOfBandData != null)
+ {
+ outOfBandInvocation.mOutOfBandData = outOfBandData;
+ }
+ Guid result = outOfBandInvocation.Invoke(methodName, parameters ?? new object[0]);
+ ThreadPool.QueueUserWorkItem(AppendOutOfBandData, outOfBandInvocation);
+ return result;
+ }
+
+ protected static void AppendOutOfBandData(object state)
+ {
+ OutOfBandInvocation outOfBandInvocation = (OutOfBandInvocation)state;
+ using (outOfBandInvocation)
+ {
+ try
+ {
+ outOfBandInvocation.WriteOutOfBandData();
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ protected OutOfBandInvocation(IPEndPoint target, byte[] key)
+ {
+ Open(target, key);
+ }
+
+ protected void WriteOutOfBandData()
+ {
+ lock (base.SyncRoot)
+ {
+ int num = 8192;
+ byte[] buffer = new byte[num];
+ long num2 = mOutOfBandData.Length;
+ mUnderlyingOutStream.Write(BitConverter.GetBytes(num2), 0, 8);
+ while (num2 > num)
+ {
+ mOutOfBandData.Read(buffer, 0, num);
+ mUnderlyingOutStream.Write(buffer, 0, num);
+ num2 -= num;
+ }
+ if (num2 > 0)
+ {
+ mOutOfBandData.Read(buffer, 0, (int)num2);
+ mUnderlyingOutStream.Write(buffer, 0, (int)num2);
+ }
+ mUnderlyingOutStream.Flush();
+ }
+ }
+ }
+}
diff --git a/Contract/PodRpcProtocol.cs b/Contract/PodRpcProtocol.cs
new file mode 100644
index 0000000..60a028e
--- /dev/null
+++ b/Contract/PodRpcProtocol.cs
@@ -0,0 +1,120 @@
+// =============================================================================
+// Tesla.Contract — Pod RPC protocol (Console <-> Launcher Service)
+// =============================================================================
+// The framed, JSON-based replacement for the old OFB-encrypted BinaryFormatter
+// RPC. Runs ON TOP of the same OFB-encrypted stream (NegotiateCryptoStreams is
+// unchanged); only the per-message serialization changed.
+//
+// Wire format, after the OFB/CONF handshake:
+// request : [4-byte little-endian length][UTF-8 JSON RpcRequest]
+// response: [4-byte little-endian length][UTF-8 JSON RpcResponse]
+// The InitiateInstallProduct out-of-band file transfer (8-byte length + raw
+// bytes) is unchanged and still follows its response frame on the same stream.
+//
+// Dispatch is by method NAME (RpcRequest.Method) — the old serialized-MethodBase
+// + SerializationBinder + MethodInfoProxy machinery is gone. Both ends share this
+// one file, so the request/response shape cannot drift.
+// =============================================================================
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+
+namespace Tesla.Net
+{
+ /// One RPC call: a method name plus its arguments as JSON elements.
+ public sealed class RpcRequest
+ {
+ public string Method { get; set; }
+ public List Args { get; set; }
+ }
+
+ /// One RPC result: the return value as JSON, or an error message.
+ public sealed class RpcResponse
+ {
+ public JsonElement Result { get; set; } // JsonValueKind.Null for void / null
+ public string Error { get; set; } // null on success
+ }
+
+ /// Framing + (de)serialization shared by the Console client and the
+ /// Launcher Service. Synchronous to match the existing stream usage.
+ public static class PodRpc
+ {
+ /// Upper bound on a single JSON frame (the bulk install payload is
+ /// streamed out-of-band, not framed), guarding against hostile lengths.
+ public const int MaxFrameBytes = 16 * 1024 * 1024;
+
+ public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
+ {
+ // The Tesla.Net wire types (LaunchData, LaunchPair, ...) expose public
+ // FIELDS, which System.Text.Json ignores unless this is set.
+ IncludeFields = true,
+ };
+
+ // ── Framing ──────────────────────────────────────────────────────────
+
+ public static void WriteFrame(Stream stream, byte[] payload)
+ {
+ if (payload.Length > MaxFrameBytes)
+ throw new IOException($"RPC frame too large ({payload.Length} bytes).");
+ var len = BitConverter.GetBytes(payload.Length); // little-endian on x86/x64
+ stream.Write(len, 0, 4);
+ stream.Write(payload, 0, payload.Length);
+ stream.Flush();
+ }
+
+ public static byte[] ReadFrame(Stream stream)
+ {
+ var lenBuf = ReadExact(stream, 4);
+ int len = BitConverter.ToInt32(lenBuf, 0);
+ if (len < 0 || len > MaxFrameBytes)
+ throw new IOException($"RPC frame length out of range ({len}).");
+ return ReadExact(stream, len);
+ }
+
+ private static byte[] ReadExact(Stream stream, int count)
+ {
+ var buf = new byte[count];
+ int off = 0;
+ while (off < count)
+ {
+ int n = stream.Read(buf, off, count - off);
+ if (n == 0) throw new EndOfStreamException("Connection closed mid-frame.");
+ off += n;
+ }
+ return buf;
+ }
+
+ // ── Request ──────────────────────────────────────────────────────────
+
+ public static void WriteRequest(Stream stream, string method, object[] args)
+ {
+ var req = new RpcRequest { Method = method, Args = new List() };
+ if (args != null)
+ foreach (var a in args)
+ req.Args.Add(JsonSerializer.SerializeToElement(a, JsonOptions));
+ WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(req, JsonOptions));
+ }
+
+ public static RpcRequest ReadRequest(Stream stream)
+ => JsonSerializer.Deserialize(ReadFrame(stream), JsonOptions);
+
+ // ── Response ─────────────────────────────────────────────────────────
+
+ public static void WriteResponse(Stream stream, object result, string error)
+ {
+ var resp = new RpcResponse
+ {
+ // Always a valid element (JSON null when there is no result): a
+ // default(JsonElement) is ValueKind.Undefined and is not serializable.
+ Result = JsonSerializer.SerializeToElement(error == null ? result : null, JsonOptions),
+ Error = error,
+ };
+ WriteFrame(stream, JsonSerializer.SerializeToUtf8Bytes(resp, JsonOptions));
+ }
+
+ public static RpcResponse ReadResponse(Stream stream)
+ => JsonSerializer.Deserialize(ReadFrame(stream), JsonOptions);
+ }
+}
diff --git a/Contract/Tesla.Contract.csproj b/Contract/Tesla.Contract.csproj
new file mode 100644
index 0000000..d6fe5b4
--- /dev/null
+++ b/Contract/Tesla.Contract.csproj
@@ -0,0 +1,47 @@
+
+
+
+
+ net48;net8.0-windows
+
+
+ TeslaConsoleLaunchLib
+ Tesla.Net
+ 1.0.0.0
+ 1.0.0.0
+ 1.0.0.0
+
+ disable
+ disable
+ latest
+ true
+
+ $(NoWarn);SYSLIB0011
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Contract/WireContract.cs b/Contract/WireContract.cs
new file mode 100644
index 0000000..7dcc34b
--- /dev/null
+++ b/Contract/WireContract.cs
@@ -0,0 +1,176 @@
+// =============================================================================
+// Tesla.Contract — Shared RPC Wire Contract (assembly: TeslaConsoleLaunchLib)
+// =============================================================================
+// Single source of truth for the Console <-> Launcher protocol. Previously this
+// lived in two hand-synced places: the vendored binary
+// Console/lib/TeslaConsoleLaunchLib.dll (consumed by the Console) and a manual
+// replica in Launcher/LaunchModels_Shared.cs (consumed by the Launcher Service).
+//
+// The wire types below are reconstructed verbatim from the original
+// TeslaConsoleLaunchLib.dll. Field NAMES, TYPES and ORDER are load-bearing:
+// they are serialized with BinaryFormatter and must match byte-for-byte or the
+// protocol breaks silently at runtime. The byte-identical guarantee is asserted
+// by WireContractCompatTests in the differential suite.
+//
+// This file is compiled for BOTH net48 (Console) and net6.0-windows (Launcher).
+// The TCP/OFB/BinaryFormatter client (PodManagerConnection) is net48-only and
+// lives under Client/ — it depends on TeslaSecureConfiguration.dll.
+// =============================================================================
+
+using System;
+using System.Net;
+using System.Reflection;
+
+namespace Tesla.Net
+{
+ /// App identifier + display name pair.
+ [Serializable]
+ public struct LaunchPair
+ {
+ public Guid LaunchKey;
+
+ public string DisplayName;
+ }
+
+ /// Describes one launchable simulation application.
+ [Serializable]
+ public struct LaunchData
+ {
+ public LaunchPair LaunchPair;
+
+ public string WorkingDirectory;
+
+ public string ExeFile;
+
+ public string Arguments;
+
+ public bool AutoRestart;
+ }
+
+ /// Tracks a currently running simulation process.
+ [Serializable]
+ public struct LaunchedAppData
+ {
+ public int ProcessId;
+
+ public Guid LaunchKey;
+ }
+
+ /// Progress of an out-of-band product installation.
+ [Serializable]
+ public struct OutOfBandProgress
+ {
+ public int PercentComplete;
+
+ public string Status;
+
+ public bool IsCompleted;
+ }
+
+ /// Complete pod state snapshot for the FullUpdate RPC.
+ [Serializable]
+ public struct FullUpdateData
+ {
+ public LaunchData[] InstalledApps;
+
+ public LaunchedAppData[] LaunchedApps;
+
+ public float VolumeLevel;
+ }
+
+ /// RPC command from the Console. Function is a MethodBase of ILauncherService.
+ [Serializable]
+ public struct InvokeCommand
+ {
+ public MethodBase Function;
+
+ public object[] Parameters;
+ }
+
+ /// RPC result returned to the Console.
+ [Serializable]
+ public struct InvokeResult
+ {
+ public object Result;
+
+ public Exception Exception;
+
+ public TimeSpan CallDuration;
+ }
+
+ public delegate void OutOfBandProgressChanged(Guid outOfBandCallId, OutOfBandProgress progress);
+
+ /// Server-side RPC surface. Method signatures are resolved by name from a
+ /// serialized MethodBase, so they must match the original exactly.
+ public interface ILauncherService
+ {
+ float VolumeLevel { get; set; }
+
+ void ClearStore();
+
+ DateTime Ping(DateTime now);
+
+ Guid InitiateInstallProduct();
+
+ OutOfBandProgress GetOutOfBandProgress(Guid invokeCallId);
+
+ int LaunchApp(Guid launchKey);
+
+ LaunchPair[] GetLaunchableApps();
+
+ void KillApp(Guid launchKey, int processId);
+
+ void KillAllOfType(Guid launchKey);
+
+ void KillAllApps();
+
+ void Shutdown(bool restart);
+
+ LaunchedAppData[] GetLaunchedApps();
+
+ LaunchData[] GetInstalledApps();
+
+ void RemoveApp(Guid index);
+
+ void InstallApp(LaunchData data);
+
+ void UninstallApp(Guid launchKey);
+
+ FullUpdateData FullUpdate();
+ }
+
+ /// Console-side connection: ILauncherService plus connection management
+ /// and the out-of-band InstallProduct file transfer.
+ public interface IPodManagerConnection : ILauncherService
+ {
+ bool IsOpen { get; }
+
+ void Open(IPEndPoint remoteEndPoint, byte[] secureKey);
+
+ void Close();
+
+ Guid InstallProduct(string filename);
+ }
+}
+
+namespace TeslaConsole
+{
+ /// Thrown by the Console-side connection when the pod link drops.
+ [Serializable]
+ public class ConnectionLostException : Exception
+ {
+ public ConnectionLostException()
+ {
+ }
+
+ public ConnectionLostException(string message)
+ : base(message)
+ {
+ }
+
+ public ConnectionLostException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/Launcher/Directory.Build.props b/Launcher/Directory.Build.props
new file mode 100644
index 0000000..6c87852
--- /dev/null
+++ b/Launcher/Directory.Build.props
@@ -0,0 +1,23 @@
+
+
+
+
+ obj\$(MSBuildProjectName)\
+
+
+
+
+ $(DefaultItemExcludes);$(MSBuildProjectDirectory)\obj\**;$(MSBuildProjectDirectory)\bin\**
+
+
+
diff --git a/Launcher/LaunchModels_Shared.cs b/Launcher/LaunchModels_Shared.cs
index e3372d4..8f8bdbf 100644
--- a/Launcher/LaunchModels_Shared.cs
+++ b/Launcher/LaunchModels_Shared.cs
@@ -1,105 +1,16 @@
// =============================================================================
-// TeslaLauncher — Shared Models
+// TeslaLauncher — Shared IPC Models
// =============================================================================
-// Wire types (Tesla.Net): BinaryFormatter-compatible replicas of the original
-// structs from TeslaConsoleLaunchLib.dll. Field names, types, and order must
-// match exactly for serialization compatibility.
+// IPC types (Tesla.Launcher.Shared): JSON messages between the Windows Service
+// and the user-session Agent over a Named Pipe. These never touch the Console
+// TCP connection.
//
-// IPC types (Tesla.Launcher.Shared): JSON messages between Service and Agent
-// over Named Pipe. These never touch the Console TCP connection.
+// The BinaryFormatter wire types (Tesla.Net: LaunchData, InvokeCommand, ...)
+// formerly replicated here by hand now live in the shared Tesla.Contract project
+// (assembly TeslaConsoleLaunchLib), referenced by the Service. This file is also
+// compiled into the Agent, which uses only the IPC types below.
// =============================================================================
-using System;
-using System.Reflection;
-using System.Runtime.Serialization;
-
-namespace Tesla.Net
-{
- /// RPC command from TeslaConsole. Function is a MethodBase of ILauncherService.
- [Serializable]
- public struct InvokeCommand
- {
- public MethodBase Function;
- public object[] Parameters;
- }
-
- /// RPC result returned to TeslaConsole.
- [Serializable]
- public struct InvokeResult
- {
- public object Result;
- public Exception Exception;
- public TimeSpan CallDuration;
- }
-
- /// App identifier + display name pair.
- [Serializable]
- public struct LaunchPair
- {
- public Guid LaunchKey;
- public string DisplayName;
- }
-
- /// Describes one launchable simulation application.
- [Serializable]
- public struct LaunchData
- {
- public LaunchPair LaunchPair;
- public string WorkingDirectory;
- public string ExeFile;
- public string Arguments;
- public bool AutoRestart;
- }
-
- /// Tracks a currently running simulation process.
- [Serializable]
- public struct LaunchedAppData
- {
- public int ProcessId;
- public Guid LaunchKey;
- }
-
- /// Complete pod state snapshot for FullUpdate RPC.
- [Serializable]
- public struct FullUpdateData
- {
- public LaunchData[] InstalledApps;
- public LaunchedAppData[] LaunchedApps;
- public float VolumeLevel;
- }
-
- /// Progress of an out-of-band product installation.
- [Serializable]
- public struct OutOfBandProgress
- {
- public int PercentComplete;
- public string Status;
- public bool IsCompleted;
- }
-
- /// Interface for BinaryFormatter MethodBase resolution. Signatures must match the original exactly.
- public interface ILauncherService
- {
- float VolumeLevel { get; set; }
- void ClearStore();
- DateTime Ping(DateTime now);
- Guid InitiateInstallProduct();
- OutOfBandProgress GetOutOfBandProgress(Guid invokeCallId);
- int LaunchApp(Guid launchKey);
- LaunchPair[] GetLaunchableApps();
- void KillApp(Guid launchKey, int processId);
- void KillAllOfType(Guid launchKey);
- void KillAllApps();
- void Shutdown(bool restart);
- LaunchedAppData[] GetLaunchedApps();
- LaunchData[] GetInstalledApps();
- void RemoveApp(Guid index);
- void InstallApp(LaunchData data);
- void UninstallApp(Guid launchKey);
- FullUpdateData FullUpdate();
- }
-}
-
namespace Tesla.Launcher.Shared
{
/// Command forwarded from the Windows Service to the Userspace Agent.
diff --git a/Launcher/SecureConfig.cs b/Launcher/SecureConfig.cs
index b5d1718..6cc9ca7 100644
--- a/Launcher/SecureConfig.cs
+++ b/Launcher/SecureConfig.cs
@@ -196,13 +196,20 @@ namespace TeslaSecureConfig
// ---- Crypto helpers ----
internal static class CryptoHelper
{
- /// Derive AES key from passphrase using PBKDF2 with the hard-coded salt
+ /// Derive AES key from passphrase using PBKDF2 with the hard-coded salt.
+ /// COMPATIBILITY: this MUST use the SHA1-default Rfc2898DeriveBytes(string,
+ /// byte[], int) overload. The Console derives the same session key with the
+ /// identical SHA1-default PBKDF2 (Tesla.PodConfigurationServer.GenerateKeyFromPassphrase),
+ /// so switching to a SHA256 overload — as SYSLIB0041 suggests on net8 — would
+ /// silently break the secure-config key handshake. Do not "modernize" this.
+#pragma warning disable SYSLIB0041 // SHA1-default PBKDF2 is required for Console wire compatibility
public static byte[] DeriveKey(string passphrase)
{
var pbkdf2 = new Rfc2898DeriveBytes(passphrase,
Proto.PassphraseSalt, Proto.Pbkdf2Iterations);
return pbkdf2.GetBytes(Proto.AesKeyBytes);
}
+#pragma warning restore SYSLIB0041
/// Encrypt data with AES-256 (CBC mode)
public static byte[] Encrypt(byte[] data, byte[] key)
diff --git a/Launcher/TeslaLauncherAgent.csproj b/Launcher/TeslaLauncherAgent.csproj
index 59a88b8..1e399ce 100644
--- a/Launcher/TeslaLauncherAgent.csproj
+++ b/Launcher/TeslaLauncherAgent.csproj
@@ -1,19 +1,18 @@
- net6.0-windows
+ net8.0-windows
WinExe
true
disable
disable
- true
0.1.0
app.ico
TeslaLauncherAgent
Tesla.Launcher.Agent
- x86
- win-x86
+ x64
+ win-x64
true
true
Tesla.Launcher.Agent.AgentApplication
@@ -27,7 +26,7 @@
-
+
diff --git a/Launcher/TeslaLauncherService.cs b/Launcher/TeslaLauncherService.cs
index f3d9d20..2a6dba7 100644
--- a/Launcher/TeslaLauncherService.cs
+++ b/Launcher/TeslaLauncherService.cs
@@ -3,7 +3,7 @@
// =============================================================================
// Replaces TeslaLauncherService.exe (Elsewhen Studios LLC, .NET Framework 2.0).
// Runs in Session 0, auto-starts at boot before user login.
-// Listens on TCP 53290 for OFB-encrypted BinaryFormatter RPC from TeslaConsole.
+// Listens on TCP 53290 for OFB-encrypted framed-JSON RPC from TeslaConsole.
// Forwards commands to TeslaLauncherAgent (user session) via Named Pipe.
//
// On first boot (DHCP detected), runs SecureConfig to receive network
@@ -17,9 +17,6 @@ using System.IO.Compression;
using System.IO.Pipes;
using System.Net;
using System.Net.Sockets;
-using System.Reflection;
-using System.Runtime.Serialization;
-using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -32,8 +29,6 @@ using TeslaSecureConfig;
using Tesla.Launcher.Shared;
using Tesla.Net;
-#pragma warning disable SYSLIB0011 // BinaryFormatter is required for Console wire compatibility
-
namespace Tesla.Launcher.Service
{
public class TeslaLauncherService : BackgroundService
@@ -92,11 +87,6 @@ namespace Tesla.Launcher.Service
_logger.LogInformation(
"Tesla Launcher Service starting on port {Port}", CONSOLE_PORT);
- // Enable BinaryFormatter at runtime. Must also be set in .csproj.
- // Both switches are required on .NET 6+.
- AppContext.SetSwitch(
- "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization", true);
-
// ── Boot diagnostic — always written so every reboot is traceable ──
// Logs adapter state to podconf.log before IsMachineConfigured() decides,
// visible even if SecureConfig does not run.
@@ -170,8 +160,8 @@ namespace Tesla.Launcher.Service
"Console connected from {EP}", client.Client.RemoteEndPoint);
// Handle each Console connection on its own thread.
- // BinaryFormatter.Deserialize is synchronous, so we
- // offload to a thread-pool thread via Task.Run.
+ // Frame reads are synchronous, so we offload to a
+ // thread-pool thread via Task.Run.
var task = Task.Run(
() => HandleConsoleClient(client, stoppingToken),
stoppingToken);
@@ -247,88 +237,70 @@ namespace Tesla.Launcher.Service
}
Stream rpcStream = ofb;
-
- var formatter = CreateFormatter();
+ var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
_logger.LogInformation("Console session started.");
try
{
while (!ct.IsCancellationRequested && client.Connected)
{
- InvokeCommand command;
+ RpcRequest request;
try
{
- MethodInfoProxy.LastResolvedName = null;
- var obj = formatter.Deserialize(rpcStream);
- command = (InvokeCommand)obj;
+ request = PodRpc.ReadRequest(rpcStream);
}
- catch (IOException)
+ catch (EndOfStreamException)
{
_logger.LogInformation("Console disconnected (EOF).");
break;
}
- catch (SerializationException ex)
- when (ex.Message.Contains("End of Stream"))
+ catch (IOException ex)
{
- _logger.LogInformation("Console disconnected (end of stream).");
- break;
- }
- catch (SerializationException ex)
- when (ex.InnerException is IOException)
- {
- // Socket timeout or reset — Console went idle or disconnected
- _logger.LogInformation("Console disconnected (I/O: {Msg}).",
- ex.InnerException.Message);
+ _logger.LogInformation("Console disconnected (I/O: {Msg}).", ex.Message);
break;
}
catch (Exception ex)
{
- _logger.LogWarning("Deserialization error ({Type}): {Msg}",
+ _logger.LogWarning("Request read error ({Type}): {Msg}",
ex.GetType().Name, ex.Message);
- File.AppendAllText(
- Path.Combine(AppContext.BaseDirectory, "podconf.log"),
- $"[{DateTime.Now:HH:mm:ss}] DESER ERROR: {ex}{Environment.NewLine}");
+ File.AppendAllText(diagLog,
+ $"[{DateTime.Now:HH:mm:ss}] REQ ERROR: {ex}{Environment.NewLine}");
break;
}
- // Fall back to ThreadStatic name if IObjectReference fixup
- // didn't update the boxed struct field.
- var funcName = command.Function?.Name
- ?? MethodInfoProxy.LastResolvedName ?? "???";
- _logger.LogInformation(
- "Command: {Func}({Args})", funcName,
- command.Parameters != null
- ? string.Join(", ", command.Parameters) : "");
+ if (request == null) break;
+
+ var funcName = request.Method ?? "???";
+ IReadOnlyList args = request.Args ?? new List();
+ _logger.LogInformation("Command: {Func}({ArgCount} args)", funcName, args.Count);
var start = DateTime.UtcNow;
- var result = new InvokeResult();
- var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
+ object resultObj = null;
+ string error = null;
try
{
- result.Result = await DispatchCommandAsync(
- funcName, command.Parameters, ct);
+ resultObj = await DispatchCommandAsync(funcName, args, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing {Func}", funcName);
- result.Exception = new Exception(ex.Message);
+ error = ex.Message;
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] CMD {funcName} ERROR: {ex.Message}{Environment.NewLine}");
}
- result.CallDuration = DateTime.UtcNow - start;
- if (result.Exception == null)
+ var dur = DateTime.UtcNow - start;
+ if (error == null)
{
File.AppendAllText(diagLog,
- $"[{DateTime.Now:HH:mm:ss}] CMD {funcName} OK ({result.CallDuration.TotalMilliseconds:F0}ms)" +
- $"{(result.Result != null ? $" → {result.Result}" : "")}{Environment.NewLine}");
+ $"[{DateTime.Now:HH:mm:ss}] CMD {funcName} OK ({dur.TotalMilliseconds:F0}ms)" +
+ $"{(resultObj != null ? $" → {resultObj}" : "")}{Environment.NewLine}");
}
try
{
- formatter.Serialize(rpcStream, result);
- rpcStream.Flush();
+ PodRpc.WriteResponse(rpcStream, resultObj, error);
}
catch (IOException)
{
@@ -337,19 +309,10 @@ namespace Tesla.Launcher.Service
}
catch (Exception serEx)
{
- // Result type not BinaryFormatter-serializable — send error instead
+ // Result type not JSON-serializable — send error instead.
_logger.LogWarning("Serialize failed for {Func}: {Msg}",
funcName, serEx.Message);
- var fallback = new InvokeResult
- {
- Exception = new Exception($"Pod error: {serEx.Message}"),
- CallDuration = result.CallDuration
- };
- try
- {
- formatter.Serialize(rpcStream, fallback);
- rpcStream.Flush();
- }
+ try { PodRpc.WriteResponse(rpcStream, null, $"Pod error: {serEx.Message}"); }
catch { break; }
}
@@ -359,8 +322,8 @@ namespace Tesla.Launcher.Service
// 8-byte Int64 file size + raw file bytes
// Then closes the connection (FIN).
if (funcName == "InitiateInstallProduct"
- && result.Exception == null
- && result.Result is Guid installGuid)
+ && error == null
+ && resultObj is Guid installGuid)
{
ReceiveInstallFile(rpcStream, installGuid, diagLog);
break; // connection done after file transfer
@@ -370,7 +333,6 @@ namespace Tesla.Launcher.Service
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in Console handler");
- var diagLog = Path.Combine(AppContext.BaseDirectory, "podconf.log");
File.AppendAllText(diagLog,
$"[{DateTime.Now:HH:mm:ss}] HANDLER ERROR: {ex.GetType().Name}: {ex.Message}{Environment.NewLine}" +
$"[{DateTime.Now:HH:mm:ss}] Inner: {ex.InnerException?.Message}{Environment.NewLine}");
@@ -380,19 +342,24 @@ namespace Tesla.Launcher.Service
}
// ── Command Dispatch ─────────────────────────────────────────────
+ // Arguments arrive as JSON elements (RpcRequest.Args); each case extracts
+ // the typed values it needs. The returned object is serialized straight
+ // into the RpcResponse.
private async Task