Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Computer">
<SettingsSwitchRow
icon="desktopcomputer"
label="Computer use"
subtitle="Allow agents to use apps on the selected environments' computers through Cua Driver. On macOS, grant Accessibility and Screen Recording on each host."
value={uniform("enableCua")}
disabled={disabledFor("enableCua")}
onValueChange={(value) => write({ enableCua: value })}
/>
</SettingsSection>
<AutoSettleSettingsRows write={write} pending={pendingWrites > 0} />
</>
) : null}
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import * as RpcTest from "effect/rpc/RpcTest";
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";

import {
RPC_REQUIRED_SCOPES,
Expand Down Expand Up @@ -147,6 +148,31 @@ describe("settings mutation authorization", () => {
instanceId: ProviderInstanceId.make("codex_work"),
};

it.effect("restores computer use alongside duration settings through authorization", () =>
Effect.gen(function* () {
const client = yield* RpcTest.makeClient(group).pipe(
Effect.provide(
Layer.mergeAll(
group.toLayerHandler(WS_METHODS.serverUpdateSettings, ({ patch }) =>
Effect.succeed(
applyServerSettingsPatch({ ...DEFAULT_SERVER_SETTINGS, enableCua: true }, patch),
),
),
RpcAuthorization.layer([AuthSettingsWriteScope]),
),
),
);
const defaults = {
enableCua: DEFAULT_SERVER_SETTINGS.enableCua,
automaticGitFetchInterval: DEFAULT_SERVER_SETTINGS.automaticGitFetchInterval,
providerHealthRefreshInterval: DEFAULT_SERVER_SETTINGS.providerHealthRefreshInterval,
};
expect(yield* client[WS_METHODS.serverUpdateSettings]({ patch: defaults })).toMatchObject(
defaults,
);
}).pipe(Effect.scoped),
);

it.effect("allows provider-only mutations while denying mixed settings without their grant", () =>
Effect.gen(function* () {
let handled = 0;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverCheckComputerUse]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
[WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/cua/CuaDriver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import { it } from "@effect/vitest";
import type { CuaDriverMcpConfiguration, DesktopCuaDriverReport } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import type { EmbeddedDriverConnection } from "@trycua/cua-driver/embedded";
import { Deferred, Effect, Exit, Fiber, Option, PubSub, Ref, Scope, Stream } from "effect";
import { TestClock } from "effect/testing";
Expand Down Expand Up @@ -501,8 +502,10 @@ describe("CuaDriver", () => {
yield* Deferred.succeed(f.result, mcp);
expect(yield* f.service.acquire).toEqual(Option.some(mcp));
yield* Deferred.await(f.watching);
expect((yield* f.service.status).running).toBe(true);
yield* Deferred.succeed(f.exited, undefined);
yield* Deferred.await(f.stopped);
expect((yield* f.service.status).running).toBe(false);
expect(f.starts()).toBe(1);
expect(yield* f.service.acquire).toEqual(Option.some(mcp));
expect(f.starts()).toBe(2);
Expand Down Expand Up @@ -543,6 +546,70 @@ describe("CuaDriver", () => {
});

describe("Cua Driver readiness", () => {
it.effect("reports the host platform and runs only between readiness and disable", () =>
Effect.scoped(
Effect.gen(function* () {
const f = yield* fixture(false).pipe(Effect.provideService(HostProcess.Platform, "darwin"));
expect(yield* f.service.status).toEqual({ platform: "darwin", running: false });
yield* f.setEnabled(true);
const caller = yield* f.service.acquire.pipe(Effect.forkChild);
yield* Deferred.await(f.started);
expect(yield* f.service.status).toEqual({ platform: "darwin", running: false });
yield* Deferred.succeed(f.result, mcp);
expect(yield* Fiber.join(caller)).toEqual(Option.some(mcp));
expect(yield* f.service.status).toEqual({ platform: "darwin", running: true });
yield* f.setEnabled(false);
yield* Deferred.await(f.stopped);
expect(yield* f.service.status).toEqual({ platform: "darwin", running: false });
expect(yield* f.service.acquire).toEqual(Option.none());
}),
),
);

it.effect("reports a host start failure until the replacement is ready", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new CuaDriver.CuaDriverStartError({
binaryPath: "/driver",
cause: new Error("native start failed"),
});
const restarting = yield* Deferred.make<void>();
const ready = yield* Deferred.make<CuaDriverMcpConfiguration>();
let attempts = 0;
const service = yield* makeCuaDriver({
enabled: Effect.succeed(true),
changes: Stream.empty,
createHost: Effect.sync(() => ({
start:
++attempts === 1
? Effect.fail(failure)
: Deferred.succeed(restarting, undefined).pipe(
Effect.andThen(Deferred.await(ready)),
),
stop: Effect.void,
waitForExit: Effect.never,
})),
}).pipe(Effect.provideService(HostProcess.Platform, "win32"));
expect(yield* service.acquire).toEqual(Option.none());
expect(yield* service.status).toEqual({
platform: "win32",
running: false,
failure: failure.message,
});
const retry = yield* service.acquire.pipe(Effect.forkChild);
yield* Deferred.await(restarting);
expect(yield* service.status).toEqual({
platform: "win32",
running: false,
failure: failure.message,
});
yield* Deferred.succeed(ready, mcp);
expect(yield* Fiber.join(retry)).toEqual(Option.some(mcp));
expect(yield* service.status).toEqual({ platform: "win32", running: true });
}),
),
);

it.effect("remembers why the last start failed until a start succeeds", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/cua/CuaDriver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import * as NodeModule from "node:module";

import type { CuaDriverMcpConfiguration, DesktopCuaDriverReport } from "@t3tools/contracts";
import type {
CuaDriverMcpConfiguration,
CuaHostStatus,
DesktopCuaDriverReport,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import type { EmbeddedCuaDriverHost, EmbeddedDriverConnection } from "@trycua/cua-driver/embedded";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -144,6 +149,7 @@ export class CuaDriver extends Context.Service<
readonly acquire: Effect.Effect<Option.Option<CuaDriverMcpConfiguration>>;
/** Why the last start failed, until a later start succeeds. */
readonly lastFailure: Effect.Effect<Option.Option<string>>;
readonly status: Effect.Effect<CuaHostStatus>;
}
>()("t3/cua/CuaDriver") {}

Expand Down Expand Up @@ -175,6 +181,8 @@ export const make = Effect.fn("CuaDriver.make")(function* (
stopped?: boolean;
/** Message of the typed error that ended this attempt, for the readiness signal. */
failure?: string;
/** Set once the host reported its MCP descriptor. */
running?: boolean;
};
let current: Attempt | undefined;
let closed = false;
Expand Down Expand Up @@ -241,6 +249,7 @@ export const make = Effect.fn("CuaDriver.make")(function* (
return;
}
lastFailure = undefined;
attempt.running = true;
yield* Deferred.succeed(attempt.ready, Option.some(mcp));
}),
);
Expand Down Expand Up @@ -290,10 +299,17 @@ export const make = Effect.fn("CuaDriver.make")(function* (
return attempt ? yield* Deferred.await(attempt.ready) : Option.none();
});

const platform = yield* HostProcess.Platform;
return CuaDriver.of({
enabled: enabled,
acquire,
lastFailure: Effect.sync(() => Option.fromNullishOr(lastFailure)),
status: Effect.sync(() => ({
platform:
platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other",
running: current?.running === true,
...(lastFailure === undefined ? {} : { failure: lastFailure }),
})),
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2",
[WS_METHODS.projectsMutate]: "orchestration",
[WS_METHODS.serverProbe]: "server",
[WS_METHODS.serverCheckComputerUse]: "server",
[WS_METHODS.serverGetConfig]: "server",
[WS_METHODS.serverRefreshProviders]: "server",
[WS_METHODS.serverUpdateProvider]: "server",
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ import * as TerminalManager from "./terminal/Manager.ts";
import { terminalOutputStream, withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
import * as ServerBrowser from "./preview/ServerBrowser.ts";
import * as DeviceService from "./device/DeviceService.ts";
import * as CuaDriver from "./cua/CuaDriver.ts";
import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts";
import * as PreviewManager from "./preview/Manager.ts";
import { issueAssetUrl } from "./assets/AssetAccess.ts";
Expand Down Expand Up @@ -2176,6 +2177,14 @@ export const layerWsRpc = (
Effect.andThen(scheduledTasks.getWebhookDelivery(input)),
),
[WS_METHODS.serverProbe]: (_input) => Effect.succeed({}),
[WS_METHODS.serverCheckComputerUse]: (_input) =>
Effect.serviceOption(CuaDriver.CuaDriver).pipe(
Effect.flatMap((driver) =>
Option.isSome(driver)
? driver.value.status
: Effect.succeed({ platform: "other", running: false } as const),
),
),
[WS_METHODS.serverGetConfig]: (_input) =>
loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }),
[WS_METHODS.serverSearchAcpRegistry]: (input) =>
Expand Down
Loading
Loading