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: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,21 +238,24 @@ skills/

### Lifecycle Safeguards

The server enforces one active desktop-control instance per Windows user profile by taking an exclusive lock at:
The server allows multiple Codex sessions to start their own MCP server process so tool discovery remains available in each session. Input-changing tools still coordinate desktop control by taking an exclusive, renewable lease at:

```text
%LOCALAPPDATA%\CodexComputerRunMCPServer\server.lock
%LOCALAPPDATA%\CodexComputerRunMCPServer\control.lock
```

If another instance is already running, startup exits with code `2` before exposing MCP tools. This prevents multiple agents from sending mouse, keyboard, and clipboard input to the same desktop at the same time.
The control lease is acquired by `move_mouse`, `click`, `scroll`, `press_key`, `hotkey`, and `type_text`. If another Codex session currently owns the lease, the tool call fails with a busy message instead of allowing simultaneous mouse, keyboard, or clipboard input. Observation tools (`screenshot`, `cursor_position`, and `list_windows`) remain available from every session.

After the latest control action, the owning process keeps the lease briefly so follow-up clicks or keystrokes from the same session are not interleaved with another session. The lease is also released immediately when the owning MCP process exits.

Idle shutdown is disabled by default so long-lived Codex sessions can call the MCP tools later without finding a closed stdio transport. If you explicitly enable idle shutdown, every tool call updates activity state and active calls are never stopped mid-invocation.

Optional environment overrides:

| Variable | Default | Detail |
|----------|---------|--------|
| `CODEX_COMPUTER_RUN_SINGLE_INSTANCE` | `true` | Set `false` to disable the single-instance lock. |
| `CODEX_COMPUTER_RUN_CONTROL_LOCK` | `true` | Set `false` to disable cross-session desktop-control coordination. |
| `CODEX_COMPUTER_RUN_CONTROL_LEASE_SECONDS` | `60` | Seconds the owning session keeps desktop control after the latest input-changing action. Set `0` to release immediately after each action. |
| `CODEX_COMPUTER_RUN_IDLE_SHUTDOWN` | `false` | Set `true` to enable idle shutdown. |
| `CODEX_COMPUTER_RUN_IDLE_TIMEOUT_SECONDS` | `300` | Seconds without tool activity before shutdown when idle shutdown is enabled. Values `0` or lower disable idle shutdown. |
| `CODEX_COMPUTER_RUN_IDLE_CHECK_INTERVAL_SECONDS` | `10` | Seconds between idle checks. |
Expand Down Expand Up @@ -353,12 +356,12 @@ dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Test
Coverage with TUnit/Microsoft Testing Platform:

```powershell
dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Tests.csproj --configuration Release -- --coverage --coverage-output .\artifacts\test-results\coverage.cobertura.xml --coverage-output-format cobertura --results-directory .\artifacts\test-results
dotnet test .\src\CodexComputerRunMCPServer.Tests\CodexComputerRunMCPServer.Tests.csproj --configuration Release -- --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura --results-directory .\artifacts\test-results
```

Current verification:
- 37 TUnit tests passed.
- Coverage: 87.50% line coverage, 66.16% branch coverage for testable code.
- 40 TUnit tests passed.
- Coverage: 89.09% line coverage, 69.01% branch coverage for testable code.
- NuGet package verification confirms `skills/codex-computer-run/SKILL.md` and `skills/codex-computer-run/agents/openai.yaml` are bundled.
- Native Win32 P/Invoke shims are excluded from coverage and verified through the service boundary plus live MCP tool discovery.

Expand Down
118 changes: 109 additions & 9 deletions src/CodexComputerRunMCPServer.Tests/ComputerRunLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public async Task ActivityTracker_TracksActiveInvocationAndCompletion()
[Test]
public async Task StaticTools_TrackActivityDuringServiceInvocation()
{
using var runtimeLock = await RuntimeMutationLock.AcquireAsync();
var service = new ActivityInspectingService();
var tracker = new ComputerRunActivityTracker();
using var restoreService = ComputerRunToolRuntime.ReplaceServiceForTests(service);
Expand Down Expand Up @@ -62,7 +63,8 @@ public async Task LifecycleOptions_DefaultKeepsLongLivedMcpTransportsOpen()
{
var options = ComputerRunLifecycleOptions.Default;

await Assert.That(options.SingleInstanceEnabled).IsTrue();
await Assert.That(options.ControlLockEnabled).IsTrue();
await Assert.That(options.ControlLeaseDuration).IsEqualTo(ComputerRunLifecycleOptions.DefaultControlLeaseDuration);
await Assert.That(options.IdleShutdownEnabled).IsFalse();
await Assert.That(options.IdleTimeout).IsEqualTo(ComputerRunLifecycleOptions.DefaultIdleTimeout);
}
Expand All @@ -73,7 +75,8 @@ public async Task LifecycleOptions_ReadsConfigurationOverrides()
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ComputerRun:SingleInstanceEnabled"] = "false",
["ComputerRun:ControlLockEnabled"] = "false",
["ComputerRun:ControlLeaseSeconds"] = "0",
["CODEX_COMPUTER_RUN_IDLE_SHUTDOWN"] = "false",
["ComputerRun:IdleTimeoutSeconds"] = "12.5",
["ComputerRun:IdleCheckIntervalSeconds"] = "1",
Expand All @@ -82,26 +85,75 @@ public async Task LifecycleOptions_ReadsConfigurationOverrides()

var options = ComputerRunLifecycleOptions.FromConfiguration(configuration);

await Assert.That(options.SingleInstanceEnabled).IsFalse();
await Assert.That(options.ControlLockEnabled).IsFalse();
await Assert.That(options.ControlLeaseDuration).IsEqualTo(TimeSpan.Zero);
await Assert.That(options.IdleShutdownEnabled).IsFalse();
await Assert.That(options.IdleTimeout).IsEqualTo(TimeSpan.FromSeconds(12.5));
await Assert.That(options.IdleCheckInterval).IsEqualTo(TimeSpan.FromSeconds(1));
}

[Test]
public async Task SingleInstanceGuard_RejectsSecondConcurrentOwner()
public async Task DesktopControlLease_RejectsSecondConcurrentOwner()
{
var lockFilePath = Path.Combine(
Path.GetTempPath(),
"codex-computer-run-tests",
Guid.NewGuid().ToString("N"),
"server.lock");
"control.lock");

using var first = SingleInstanceGuard.TryAcquire(enabled: true, lockFilePath: lockFilePath);
using var second = SingleInstanceGuard.TryAcquire(enabled: true, lockFilePath: lockFilePath);
using var first = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath);
using var second = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath);
using var firstControl = first.BeginControlInvocation();

await Assert.That(first.HasOwnership).IsTrue();
await Assert.That(second.HasOwnership).IsFalse();
await Assert.That(Throws<InvalidOperationException>(() =>
{
using var secondControl = second.BeginControlInvocation();
})).IsTrue();
}

[Test]
public async Task DesktopControlLease_ReleasesAfterIdleLeaseExpires()
{
var clock = new ManualTimeProvider(new DateTimeOffset(2026, 5, 12, 12, 0, 0, TimeSpan.Zero));
var lockFilePath = Path.Combine(
Path.GetTempPath(),
"codex-computer-run-tests",
Guid.NewGuid().ToString("N"),
"control.lock");

using var first = new DesktopControlLease(enabled: true, TimeSpan.FromDays(1), lockFilePath, clock);
using var second = new DesktopControlLease(enabled: true, TimeSpan.FromDays(1), lockFilePath, clock);

first.BeginControlInvocation().Dispose();
clock.Advance(TimeSpan.FromDays(2));

await Assert.That(first.ReleaseExpiredIdleLease()).IsTrue();
using var secondControl = second.BeginControlInvocation();
}

[Test]
public async Task StaticTools_ApplyControlLeaseOnlyToInputChangingActions()
{
using var runtimeLock = await RuntimeMutationLock.AcquireAsync();
var lockFilePath = Path.Combine(
Path.GetTempPath(),
"codex-computer-run-tests",
Guid.NewGuid().ToString("N"),
"control.lock");
var service = new ControlLeaseInspectingService();

using var competingLease = new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath);
using var competingControl = competingLease.BeginControlInvocation();
using var restoreService = ComputerRunToolRuntime.ReplaceServiceForTests(service);
using var restoreLease = ComputerRunToolRuntime.ReplaceControlLeaseForTests(
new DesktopControlLease(enabled: true, TimeSpan.FromMinutes(1), lockFilePath));

_ = ComputerRunTools.cursor_position();
var moveRejected = Throws<InvalidOperationException>(() => ComputerRunTools.move_mouse(1, 2));

await Assert.That(moveRejected).IsTrue();
await Assert.That(service.CursorCalls).IsEqualTo(1);
await Assert.That(service.MoveCalls).IsEqualTo(0);
}

private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider
Expand Down Expand Up @@ -140,4 +192,52 @@ public string Click(int? x, int? y, string button, int clicks, double interval,

public string ListWindows(int limit) => throw new NotSupportedException();
}

private sealed class ControlLeaseInspectingService : IComputerRunService
{
public int CursorCalls { get; private set; }

public int MoveCalls { get; private set; }

public string CursorPosition()
{
CursorCalls++;
return "{}";
}

public string MoveMouse(int x, int y, double? delay)
{
MoveCalls++;
return "move";
}

public CallToolResult Screenshot(string? path, bool includeImage) => throw new NotSupportedException();

public string Click(int? x, int? y, string button, int clicks, double interval, double? delay)
=> throw new NotSupportedException();

public string Scroll(int amount, int? x, int? y, double? delay) => throw new NotSupportedException();

public string PressKey(string key, double duration, double? delay) => throw new NotSupportedException();

public string Hotkey(string keys, double? delay) => throw new NotSupportedException();

public string TypeText(string text, double? delay) => throw new NotSupportedException();

public string ListWindows(int limit) => throw new NotSupportedException();
}

private static bool Throws<TException>(Action action)
where TException : Exception
{
try
{
action();
return false;
}
catch (TException)
{
return true;
}
}
}
4 changes: 4 additions & 0 deletions src/CodexComputerRunMCPServer.Tests/McpIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class McpIntegrationTests
[Test]
public async Task Host_CreatesMcpHostedService()
{
using var runtimeLock = await RuntimeMutationLock.AcquireAsync();
using var host = Program.CreateHost([]);
var hostedServices = host.Services.GetServices<IHostedService>();

Expand Down Expand Up @@ -51,8 +52,11 @@ public async Task ToolSurface_HasDescriptionsForCodexToolDiscovery()
[Test]
public async Task StaticToolFacade_DelegatesToRuntimeService()
{
using var runtimeLock = await RuntimeMutationLock.AcquireAsync();
var service = new TestComputerRunService();
using var restore = ComputerRunToolRuntime.ReplaceServiceForTests(service);
using var restoreLease = ComputerRunToolRuntime.ReplaceControlLeaseForTests(
new DesktopControlLease(enabled: false, TimeSpan.Zero));

_ = ComputerRunTools.move_mouse(1, 2);
_ = ComputerRunTools.click(button: "middle");
Expand Down
17 changes: 17 additions & 0 deletions src/CodexComputerRunMCPServer.Tests/RuntimeMutationLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace CodexComputerRunMCPServer.Tests;

internal static class RuntimeMutationLock
{
private static readonly SemaphoreSlim Semaphore = new(1, 1);

public static async Task<IDisposable> AcquireAsync()
{
await Semaphore.WaitAsync().ConfigureAwait(false);
return new Releaser();
}

private sealed class Releaser : IDisposable
{
public void Dispose() => Semaphore.Release();
}
}
31 changes: 23 additions & 8 deletions src/CodexComputerRunMCPServer/ComputerRunLifecycleOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,23 @@ namespace CodexComputerRunMCPServer;
/// <summary>
/// Configures process lifecycle safeguards for the computer run MCP server.
/// </summary>
/// <param name="SingleInstanceEnabled">Whether startup should reject concurrent server instances.</param>
/// <param name="ControlLockEnabled">Whether desktop-control actions should be coordinated across server instances.</param>
/// <param name="ControlLeaseDuration">How long a server keeps desktop control after its latest control action.</param>
/// <param name="IdleShutdownEnabled">Whether the host should stop after a period without tool activity.</param>
/// <param name="IdleTimeout">How long the server may remain unused before it shuts down.</param>
/// <param name="IdleCheckInterval">How often idle state is checked.</param>
internal sealed record ComputerRunLifecycleOptions(
bool SingleInstanceEnabled,
bool ControlLockEnabled,
TimeSpan ControlLeaseDuration,
bool IdleShutdownEnabled,
TimeSpan IdleTimeout,
TimeSpan IdleCheckInterval)
{
/// <summary>
/// Default time a session keeps desktop-control ownership after its latest input-changing action.
/// </summary>
public static readonly TimeSpan DefaultControlLeaseDuration = TimeSpan.FromSeconds(60);

/// <summary>
/// Default time without MCP tool use before the server stops itself.
/// </summary>
Expand All @@ -30,7 +37,8 @@ internal sealed record ComputerRunLifecycleOptions(
/// Gets the default lifecycle options.
/// </summary>
public static ComputerRunLifecycleOptions Default { get; } = new(
SingleInstanceEnabled: true,
ControlLockEnabled: true,
ControlLeaseDuration: DefaultControlLeaseDuration,
IdleShutdownEnabled: false,
IdleTimeout: DefaultIdleTimeout,
IdleCheckInterval: DefaultIdleCheckInterval);
Expand All @@ -44,11 +52,17 @@ public static ComputerRunLifecycleOptions FromConfiguration(IConfiguration confi
{
ArgumentNullException.ThrowIfNull(configuration);

var singleInstanceEnabled = ReadBoolean(
var controlLockEnabled = ReadBoolean(
configuration,
"ControlLockEnabled",
"CODEX_COMPUTER_RUN_CONTROL_LOCK",
Default.ControlLockEnabled);

var controlLeaseDuration = ReadSeconds(
configuration,
"SingleInstanceEnabled",
"CODEX_COMPUTER_RUN_SINGLE_INSTANCE",
Default.SingleInstanceEnabled);
"ControlLeaseSeconds",
"CODEX_COMPUTER_RUN_CONTROL_LEASE_SECONDS",
Default.ControlLeaseDuration);

var idleShutdownEnabled = ReadBoolean(
configuration,
Expand Down Expand Up @@ -79,7 +93,8 @@ public static ComputerRunLifecycleOptions FromConfiguration(IConfiguration confi
}

return new ComputerRunLifecycleOptions(
singleInstanceEnabled,
controlLockEnabled,
controlLeaseDuration < TimeSpan.Zero ? Default.ControlLeaseDuration : controlLeaseDuration,
idleShutdownEnabled,
idleTimeout,
idleCheckInterval);
Expand Down
Loading
Loading