Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,20 @@ dist/

# Tesseract.js downloads language data into cwd by default
*.traineddata

# .NET / MSBuild output (used by apps/agent-runner/bridges/windows)
bin/
obj/
*.suo
*.user
*.userprefs

# JetBrains
.idea/
*.iml

# Visual Studio
.vs/

# Local dev junk
*.swp
32 changes: 32 additions & 0 deletions apps/agent-runner/bridges/windows/AgentMark.Bridge.Windows.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>AgentMark.Bridge.Windows</RootNamespace>
<AssemblyName>agentmark-bridge-windows</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12.0</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Build to a predictable output path so the Node-side spawn knows
where to find the exe. -->
<BaseOutputPath>$(MSBuildThisFileDirectory)bin\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)obj\</BaseIntermediateOutputPath>
</PropertyGroup>

<PropertyGroup>
<!-- The bridge talks to UIA (Windows accessibility API). FlaUI is a
maintained MIT-licensed wrapper around UIA. -->
<Description>AgentMark Windows UIA bridge — accessibility-tree capture and action execution for kind:'desktop' snapshots.</Description>
<Authors>ThinkFleet</Authors>
<Company>ThinkFleet</Company>
<Product>AgentMark Desktop</Product>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FlaUI.Core" Version="5.0.0" />
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
</ItemGroup>

</Project>
220 changes: 220 additions & 0 deletions apps/agent-runner/bridges/windows/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// AgentMark Windows UIA Bridge
// ----------------------------
// Stdio JSON-RPC 2.0 server. Parent process (typically the Node-side
// `WindowsUiaBackend` running inside the AgentMark MCP server, or a
// ThinkFleet SaaS agent) launches this exe and talks to it over
// stdin/stdout. One line of JSON per message.
//
// Protocol:
// Request : { "jsonrpc": "2.0", "id": <any>, "method": "<name>", "params": {...} }
// Response: { "jsonrpc": "2.0", "id": <same>, "result": {...} } on success
// { "jsonrpc": "2.0", "id": <same>, "error": { "code": N, "message": "..." } } on failure
//
// All diagnostic output goes to stderr — never stdout — so the framing stays clean.
//
// Methods (current set; more land as Phase 0e progresses):
// ping — returns { pong: true, version: "...", arch: "arm64|x64" }
// capabilities— returns { methods: [...], uia_version: "..." }
//
// Phase 0e2/0e3 will add:
// capture — walk a window's UIA tree, return DesktopCapture JSON
// execute — drive an action by element_id (click/type/select/etc.)
// list_windows— enumerate top-level windows visible to the bridge

using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace AgentMark.Bridge.Windows;

internal static class Program
{
// JSON serialization options — camelCase to match the AgentMark
// wire format on the Node side.
internal static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};

public static int Main(string[] args)
{
// Force UTF-8 on both pipes -- Windows defaults can mangle non-ASCII.
// Use UTF8Encoding(false) to avoid emitting a BOM on stdout, which
// would break JSON-RPC framing on the parent side.
Console.InputEncoding = new UTF8Encoding(false);
Console.OutputEncoding = new UTF8Encoding(false);
Console.Error.WriteLine($"[bridge] agentmark-bridge-windows starting (pid={Environment.ProcessId}, arch={System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})");

// Synchronous read loop. Async stdin reads on Windows pipes have a
// known issue where ReadLineAsync() doesn't always return null on
// EOF, leaving the process hung even after the parent closes the
// pipe. ReadLine() handles EOF correctly. UIA calls are themselves
// blocking COM-STA invocations so async doesn't buy us anything.
var dispatcher = new RpcDispatcher();

string? line;
while ((line = Console.In.ReadLine()) != null)
{
line = line.Trim();
if (line.Length == 0) continue;

JsonElement reqId = default;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
reqId = root.TryGetProperty("id", out var idEl) ? idEl.Clone() : default;

var method = root.GetProperty("method").GetString()
?? throw new RpcException(RpcError.InvalidRequest, "method is required");
var paramsEl = root.TryGetProperty("params", out var p) ? p : default;

var result = dispatcher.Dispatch(method, paramsEl);
Console.Out.WriteLine(EncodeSuccess(reqId, result));
}
catch (RpcException rex)
{
Console.Out.WriteLine(EncodeError(reqId, rex.Error.Code, rex.Message));
}
catch (JsonException jex)
{
Console.Out.WriteLine(EncodeError(reqId, RpcError.ParseError.Code, $"JSON parse error: {jex.Message}"));
}
catch (Exception ex)
{
Console.Error.WriteLine($"[bridge] unhandled: {ex}");
Console.Out.WriteLine(EncodeError(reqId, RpcError.InternalError.Code, ex.Message));
}

Console.Out.Flush();
}

Console.Error.WriteLine("[bridge] stdin closed; exiting");
return 0;
}

private static string EncodeSuccess(JsonElement id, object? result)
{
var env = new RpcResponseSuccess
{
Id = id.ValueKind == JsonValueKind.Undefined ? null : id,
Result = result,
};
return JsonSerializer.Serialize(env, JsonOpts);
}

private static string EncodeError(JsonElement id, int code, string message)
{
var env = new RpcResponseError
{
Id = id.ValueKind == JsonValueKind.Undefined ? null : id,
Error = new RpcErrorBody { Code = code, Message = message },
};
return JsonSerializer.Serialize(env, JsonOpts);
}
}

// ──────────────────────────────────────────────────────────────────────
// Wire format
// ──────────────────────────────────────────────────────────────────────

internal sealed class RpcResponseSuccess
{
[JsonPropertyName("jsonrpc")]
public string Jsonrpc => "2.0";

[JsonPropertyName("id")]
public JsonElement? Id { get; init; }

[JsonPropertyName("result")]
public object? Result { get; init; }
}

internal sealed class RpcResponseError
{
[JsonPropertyName("jsonrpc")]
public string Jsonrpc => "2.0";

[JsonPropertyName("id")]
public JsonElement? Id { get; init; }

[JsonPropertyName("error")]
public RpcErrorBody Error { get; init; } = null!;
}

internal sealed class RpcErrorBody
{
[JsonPropertyName("code")]
public int Code { get; init; }

[JsonPropertyName("message")]
public string Message { get; init; } = "";
}

// ──────────────────────────────────────────────────────────────────────
// Error catalog
// ──────────────────────────────────────────────────────────────────────

internal readonly record struct RpcError(int Code)
{
public static readonly RpcError ParseError = new(-32700);
public static readonly RpcError InvalidRequest = new(-32600);
public static readonly RpcError MethodNotFound = new(-32601);
public static readonly RpcError InvalidParams = new(-32602);
public static readonly RpcError InternalError = new(-32603);

// Bridge-specific (above -32000)
public static readonly RpcError WindowNotFound = new(-32010);
public static readonly RpcError ElementNotFound = new(-32011);
public static readonly RpcError UnsupportedPattern = new(-32012);
public static readonly RpcError ActionFailed = new(-32013);
}

internal sealed class RpcException(RpcError error, string message) : Exception(message)
{
public RpcError Error { get; } = error;
}

// ──────────────────────────────────────────────────────────────────────
// Dispatcher
// ──────────────────────────────────────────────────────────────────────

internal sealed class RpcDispatcher
{
private static readonly string BridgeVersion = "0.4.0";

public object? Dispatch(string method, JsonElement @params)
{
return method switch
{
"ping" => HandlePing(),
"capabilities" => HandleCapabilities(),
_ => throw new RpcException(
RpcError.MethodNotFound,
$"Unknown method: {method}. Supported: ping, capabilities."),
};
}

private static object HandlePing() => new
{
pong = true,
version = BridgeVersion,
arch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
processId = Environment.ProcessId,
};

private static object HandleCapabilities() => new
{
bridge = "agentmark-bridge-windows",
version = BridgeVersion,
methods = new[]
{
"ping",
"capabilities",
// "capture" and "execute" land in Phase 0e2/0e3
},
uiaProvider = "FlaUI.UIA3",
platform = "windows",
};
}
85 changes: 85 additions & 0 deletions apps/agent-runner/bridges/windows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# AgentMark Windows UIA Bridge

The Windows-side sidecar process for AgentMark Desktop. Walks the OS
accessibility tree via UIA (using [FlaUI](https://github.com/FlaUI/FlaUI),
MIT) and exposes capture/execute operations the Node-side
`WindowsUiaBackend` consumes through stdio JSON-RPC.

This is one implementation of the `DesktopCaptureBackend` interface
defined in `@thinkfleet/agentmark` v0.4. macOS (AXAPI) and Linux (AT-SPI)
bridges follow the same protocol.

## Build

Requires the .NET 8 SDK. From this directory:

```powershell
dotnet build
```

Output exe: `bin\Debug\net8.0-windows\agentmark-bridge-windows.exe`.

## Run + smoke test

The bridge speaks stdio JSON-RPC 2.0. One JSON message per line.

```powershell
.\bin\Debug\net8.0-windows\agentmark-bridge-windows.exe
```

It blocks waiting on stdin. Paste a `ping`:

```
{"jsonrpc":"2.0","id":1,"method":"ping"}
```

Expected response (single line, here pretty-printed):

```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"pong": true,
"version": "0.4.0",
"arch": "arm64",
"processId": 12345
}
}
```

Ctrl-Z then Enter closes stdin and the bridge exits cleanly.

## Protocol

All responses are JSON-RPC 2.0. Diagnostic output goes to stderr only —
stdout is reserved for framed JSON messages.

| Method | Status | Description |
|---|---|---|
| `ping` | Phase 0e1 ✅ | Liveness check, returns pong + bridge version |
| `capabilities` | Phase 0e1 ✅ | Lists supported methods and UIA provider info |
| `list_windows` | Phase 0e2 (planned) | Enumerate top-level windows visible to UIA |
| `capture` | Phase 0e2 (planned) | Walk a window's UIA tree → `DesktopCapture` JSON |
| `execute` | Phase 0e3 (planned) | Drive an action by element_id (click/type/select/etc.) |

## Architecture notes

- **Single-threaded for now.** UIA is COM-STA; once `capture` / `execute`
land we marshal those calls onto a dedicated STA thread.
- **UTF-8 forced on both pipes.** Windows defaults will mangle non-ASCII
in window titles / cell values otherwise.
- **All stderr goes to the parent process's stderr.** Useful for log
aggregation in the AgentMark MCP server when this bridge is spawned
as a subprocess.

## Security posture

This bridge runs as the logged-in user (never SYSTEM/admin). It can
only see/drive windows that user can already see/drive — no privilege
escalation. Stdio mode has zero network surface; only the parent
process that spawned this exe can write to its stdin.

A future `--transport=ws` mode (Phase 0f) will add a localhost-only
WebSocket transport with auth-token gating, origin-header rejection,
and process-identity verification for multi-consumer scenarios.
Loading
Loading