Skip to content
Merged
15 changes: 15 additions & 0 deletions apps/desktop/computer-use-native/computer_use_native_interlock.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ struct InterlockVerdict {
}
return {};
}
// The error code a dispatch gives when it refuses (the preflight code without its "native_"
// prefix: Main adds the prefix back). One reason, one code; empty when there is no verdict.
[[nodiscard]] std::string_view DispatchCode() const {
switch (reason) {
case InterlockReason::kShellSurface:
return "shell_surface_blocked";
case InterlockReason::kExecutionTrigger:
return "execution_trigger_blocked";
case InterlockReason::kApprovalClosed:
return "approval_not_enabled";
case InterlockReason::kNone:
break;
}
return {};
}
// The receipt's decision for this refusal (the first column of the receipt table).
[[nodiscard]] std::string_view DecisionName() const {
switch (outcome) {
Expand Down
271 changes: 271 additions & 0 deletions apps/desktop/computer-use-native/computer_use_protocol_fuzz.cc

Large diffs are not rendered by default.

160 changes: 152 additions & 8 deletions apps/desktop/computer-use-native/computer_use_windows_host.cc
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
#include "computer_use_windows_uia_source.h"
#include "computer_use_windows_shadow_scheduler.h"
#include "computer_use_windows_interlock_capture.h"
#include "computer_use_native_refusal_table.h"

namespace {

Expand Down Expand Up @@ -4346,6 +4347,44 @@ bool WindowsRecentInputMayPair() noexcept {
bool WindowsClickOpens() noexcept {
return WindowsSingleClickOpens() || WindowsRecentInputMayPair();
}

// What the interlock evaluation knew about the click when it ran (#500 S5-c-2 PR-5, review): whether
// the click is followed at all, whether it was already taken as opening, and the system's last input
// time at that moment. The evaluation can take up to the lane's hold limit; this lets the dispatch
// tell, just before it sends the click, that input came in after the evaluation.
struct WindowsClickBasis {
bool tracked = false;
bool opens = false;
bool input_read = false;
DWORD input_tick = 0;
};

bool WindowsReadLastInputTick(DWORD *tick) noexcept {
LASTINPUTINFO info{};
info.cbSize = sizeof(info);
if (!GetLastInputInfo(&info)) return false;
*tick = info.dwTime;
return true;
}

// Whether input has come in since the evaluation, for a click the evaluation judged as NOT opening
// (a click already taken as opening has nothing to lose). The dispatch then stops the click before
// it is sent (fail closed): the verdict was made for a moment that is no longer the present one, and
// a person's click that now pairs with ours would open the item. A last input time that cannot be
// read counts as moved. The cost: a person moving the mouse during the evaluation stops the click
// (the agent tries again).
bool WindowsInputMovedSinceEvaluation(const WindowsClickBasis &basis) noexcept {
if (!basis.tracked || basis.opens) return false;
DWORD now_tick = 0;
if (!basis.input_read || !WindowsReadLastInputTick(&now_tick)) return true;
return now_tick != basis.input_tick;
}

// The v2 interlocks' answer for an action whose V1 binding is valid (defined with the synchronous
// entry): a refusal, or no verdict. A click fills `basis` with what the evaluation knew.
sprint_coder::computer_use::InterlockEvaluation
WindowsInterlockRefusal(const WindowsSession &session, const std::string &metadata,
const std::string &kind, WindowsClickBasis *basis = nullptr) noexcept;
// END click rule

// BEGIN windows action point
Expand Down Expand Up @@ -4377,6 +4416,31 @@ bool DispatchWindowsAction(const WindowsSession &session,
RECT bounds{};
if (!ValidateWindowsDispatchBinding(session, metadata, &kind, &bounds, reason))
return false;
// The v2 interlocks again, right before any input (#500 S5-c-2 PR-5): the screen can have changed
// since the preflight (the ticket lives seconds), and the click facts (the last input time) are read
// again by the evaluation. A refusal is made before the first input API attempt, SetCursorPos or
// SendInput. No verdict leaves the V1 guards below as they are.
WindowsClickBasis click_basis;
{
const sprint_coder::computer_use::InterlockEvaluation evaluation =
WindowsInterlockRefusal(session, metadata, kind, &click_basis);
if (evaluation.Refuses()) {
*reason = std::string(evaluation.verdict.DispatchCode());
return false;
}
}
// The evaluation took time (up to the lane's hold limit) after the binding check, and the focus
// moved to another control of the same window in that time would send the key or the text to an
// element the observation never saw. The same check as the binding's, again, before any input.
if (kind == "key" || kind == "type") {
std::string current_focused_signature;
if (!session.has_focused_element_signature ||
!WindowsFocusedElementSignature(session.pid, bounds, &current_focused_signature) ||
current_focused_signature != session.focused_element_signature) {
*reason = "focused_target_changed";
return false;
}
}
bool dispatched = false;
if (kind == "click") {
double x = 0;
Expand Down Expand Up @@ -4434,6 +4498,14 @@ bool DispatchWindowsAction(const WindowsSession &session,
*reason = "stale_target";
return false;
}
// Input came in after the interlock evaluation judged this click as not opening: it is not sent.
// The reuse of "focused_target_changed" is on purpose (Main maps every code of this kind to the
// same plain rejection): for a click it means "input from elsewhere came in after the
// evaluation", not that the focus moved.
if (WindowsInputMovedSinceEvaluation(click_basis)) {
*reason = "focused_target_changed";
return false;
}
INPUT events[2]{};
events[0].type = INPUT_MOUSE;
events[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
Expand Down Expand Up @@ -4726,13 +4798,19 @@ std::string WindowsPreflightReceipt(const WindowsSession &session,
std::uint64_t observation_revision,
std::string_view decision,
std::string_view denied_reason,
const std::string &ticket) {
const std::string &ticket,
std::string_view refusal_result = "rejected") {
const bool denied = decision == "denied";
const bool ordinary = decision == "ordinary";
// A v2 refusal (blocked / takeover) carries its reason and the result of its own table row.
const bool refused = decision == "blocked" || decision == "takeover";
const bool with_reason = denied || refused;
return "{\"decision\":\"" + std::string(decision) +
"\",\"reasonCode\":" +
(denied ? "\"" + std::string(denied_reason) + "\"" : std::string("null")) +
",\"deniedResult\":" + (denied ? "\"rejected\"" : "null") +
(with_reason ? "\"" + std::string(denied_reason) + "\"" : std::string("null")) +
",\"deniedResult\":" +
(denied ? "\"rejected\""
: (refused ? "\"" + std::string(refusal_result) + "\"" : std::string("null"))) +
",\"ticket\":" + (ordinary ? "\"" + ticket + "\"" : "null") +
",\"classifierVersion\":" +
std::to_string(sprint_coder::computer_use::kNativePreflightClassifierVersion) +
Expand Down Expand Up @@ -4872,9 +4950,10 @@ void ScheduleWindowsEffectShadow(const WindowsSession &session,
// incomplete facts are NO VERDICT (the answer carries the reason): never a refusal and never an
// approval, so the V1 preflight decides.
//
// Behaviour-neutral: NOTHING calls this yet (the preflight and the dispatch guard are wired later in
// S5-c-2). The caller must call it only AFTER the V1 re-validation of the bound target succeeded
// (ValidateWindowsDispatchBinding): the point is only right for a binding that is still valid.
// Called from exactly two places, through WindowsInterlockRefusal below: the preflight, before it
// issues a ticket, and the dispatch, before any input (#500 S5-c-2 PR-5). The caller must call it
// only AFTER the V1 re-validation of the bound target succeeded (ValidateWindowsDispatchBinding): the
// point is only right for a binding that is still valid.
//
// THE SERIAL LANE IS HELD AT MOST kInterlockLaneHoldLimit. The lane's thread is an STA (the picker
// and the WinRT capture need it), and a UI Automation call to an app that does not answer is
Expand Down Expand Up @@ -4915,9 +4994,10 @@ std::atomic<bool> &InterlockWorkerRunning() {
return *running;
}

[[maybe_unused]] sprint_coder::computer_use::InterlockEvaluation EvaluateWindowsInterlockOnLane(
sprint_coder::computer_use::InterlockEvaluation EvaluateWindowsInterlockOnLane(
const WindowsSession &session, const std::string &metadata, const std::string &kind,
const sprint_coder::computer_use::EffectRuleset &ruleset) noexcept {
const sprint_coder::computer_use::EffectRuleset &ruleset,
WindowsClickBasis *basis = nullptr) noexcept {
using namespace sprint_coder::computer_use;
InterlockEvaluation unavailable;
unavailable.no_verdict = InterlockNoVerdict::kIncomplete;
Expand All @@ -4942,7 +5022,16 @@ std::atomic<bool> &InterlockWorkerRunning() {
// wait) and handed to the worker by value: the worker touches no session and no system state.
// The time of the last input is read at this request, not when a worker finally runs.
const bool click_tracked = kind == "click" && point_known;
// The last input time first, then the click facts (which read it again): the time of the basis is
// never later than the one the verdict used.
if (basis != nullptr && click_tracked) {
basis->input_read = WindowsReadLastInputTick(&basis->input_tick);
}
const bool click_opens = click_tracked && WindowsClickOpens();
if (basis != nullptr) {
basis->tracked = click_tracked;
basis->opens = click_opens;
}
// Everything that can throw is prepared BEFORE the single-flight slot is taken, so a failure
// here cannot leave the slot taken for ever.
auto pending = std::make_shared<InterlockPending>();
Expand Down Expand Up @@ -5032,6 +5121,39 @@ std::atomic<bool> &InterlockWorkerRunning() {
return unavailable;
}
}
// Windows has no approval path (the route v2 build is refused for win32 by
// build-computer-use-eligibility.mjs), so a file opened from a file manager or a download list is a
// closed approval.
constexpr sprint_coder::computer_use::EffectRuleset kWindowsInterlockRuleset{
/*download_open=*/false, /*file_manager_open=*/false};

// How often the interlocks had NO verdict, by reason, and how often the lane's time ran out. Only
// enum names and counts are written, never a label or a title.
void CountWindowsInterlockNoVerdict(sprint_coder::computer_use::InterlockNoVerdict reason) noexcept {
using sprint_coder::computer_use::InterlockNoVerdict;
static std::atomic<std::uint64_t> counts[4];
const std::size_t index = static_cast<std::size_t>(reason);
if (index >= 4) return;
const std::uint64_t count = counts[index].fetch_add(1, std::memory_order_relaxed) + 1;
const char *name = reason == InterlockNoVerdict::kCanceled
? "canceled"
: (reason == InterlockNoVerdict::kBudget ? "budget" : "incomplete");
char line[96];
const int written = std::snprintf(line, sizeof(line), "computer-use interlock: no verdict %s count=%llu\n",
name, static_cast<unsigned long long>(count));
if (written > 0) OutputDebugStringA(line);
}

sprint_coder::computer_use::InterlockEvaluation
WindowsInterlockRefusal(const WindowsSession &session, const std::string &metadata,
const std::string &kind, WindowsClickBasis *basis) noexcept {
const sprint_coder::computer_use::InterlockEvaluation evaluation =
EvaluateWindowsInterlockOnLane(session, metadata, kind, kWindowsInterlockRuleset, basis);
if (!evaluation.Refuses() &&
evaluation.no_verdict != sprint_coder::computer_use::InterlockNoVerdict::kNone)
CountWindowsInterlockNoVerdict(evaluation.no_verdict);
return evaluation;
}
// END sync interlock entry

bool HandleWindowsPreflight(HANDLE pipe, const Frame &request,
Expand Down Expand Up @@ -5082,6 +5204,28 @@ bool HandleWindowsPreflight(HANDLE pipe, const Frame &request,
ScheduleWindowsEffectShadow(session, metadata, kind, false, false);
return denied_sent;
}
// The v2 interlocks (#500 S5-c-2 PR-5): AFTER the V1 binding is valid and BEFORE a ticket is
// issued. A refusal issues no ticket (the slot was invalidated above). No verdict (budget,
// incomplete facts, canceled) leaves the V1 answer: an ordinary ticket as before.
{
const sprint_coder::computer_use::InterlockEvaluation evaluation =
WindowsInterlockRefusal(session, metadata, kind);
if (evaluation.Refuses()) {
const std::string decision(evaluation.verdict.DecisionName());
const std::string code(evaluation.verdict.ReasonCode());
const std::string result(evaluation.verdict.DeniedResult());
const bool sent = Respond(
pipe, request, MessageType::kPreflightResult,
sprint_coder::computer_use::NativePreflightRefusalAllowed(decision, code, result)
? WindowsPreflightReceipt(session, request_id, cancel_epoch, observation_revision,
decision, code, "", result)
: WindowsPreflightReceipt(session, request_id, cancel_epoch, observation_revision,
"denied", "native_preflight_invalid", ""));
// The V1 answer is "no ticket": the shadow records that V1 did not issue its ordinary one.
ScheduleWindowsEffectShadow(session, metadata, kind, true, false);
return sent;
}
}
std::string token;
const bool issued =
GenerateTicketToken(&token) &&
Expand Down
57 changes: 57 additions & 0 deletions apps/desktop/src/main/computer-use-native-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ComputerUseNativeBinding } from './computer-use-native';
import { projectComputerUseAccessibilityTree } from './computer-use-accessibility-tree';
import {
COMPUTER_USE_NATIVE_PICKER_UNAVAILABLE,
NATIVE_PREFLIGHT_REFUSALS,
classifyComputerUseNativeDispatchFailure,
createComputerUseNativeHost,
parseNativeApprovalApproveReceipt,
Expand Down Expand Up @@ -78,6 +79,62 @@ describe('Computer Use native Main adapter', () => {
).toBeNull();
});

it('maps the three v2 interlock dispatch codes to the result of their preflight row (#500 S5-c-2 PR-5)', () => {
const refusal = (code: string, accepted: boolean | undefined = false) =>
classifyComputerUseNativeDispatchFailure(
Object.assign(new Error('refused'), {
code,
...(accepted === undefined ? {} : { accepted }),
}),
);
// The result is the one of the same row of the receipt table: a block is rejected, an execution
// trigger is paused for a person.
expect(refusal('SHELL_SURFACE_BLOCKED')).toEqual({
result: 'rejected',
reasonCode: 'native_shell_surface_blocked',
});
expect(refusal('EXECUTION_TRIGGER_BLOCKED')).toEqual({
result: 'paused',
reasonCode: 'native_execution_trigger_blocked',
});
expect(refusal('APPROVAL_NOT_ENABLED')).toEqual({
result: 'rejected',
reasonCode: 'native_approval_not_enabled',
});
// The native spells the code in lower case: the same.
expect(refusal('execution_trigger_blocked')).toMatchObject({ result: 'paused' });
// Every mapped code has a row in the table, with the same result (a table change cannot leave the
// mapping behind).
for (const code of [
'SHELL_SURFACE_BLOCKED',
'EXECUTION_TRIGGER_BLOCKED',
'APPROVAL_NOT_ENABLED',
]) {
const mapped = refusal(code)!;
expect(
NATIVE_PREFLIGHT_REFUSALS.some(
(entry) => entry.reasonCode === mapped.reasonCode && entry.deniedResult === mapped.result,
),
code,
).toBe(true);
}
// The V1 codes keep their plain rejection (the mapping of V1 is unchanged).
for (const code of [
'HIGH_IMPACT_BLOCKED',
'SECURE_FIELD_BLOCKED',
'STALE_TARGET',
'TARGET_UNCLASSIFIED',
])
expect(refusal(code)).toEqual({
result: 'rejected',
reasonCode: `native_${code.toLowerCase()}`,
});
// An interlock code that is not marked as refused before input is not taken as a pre-dispatch
// refusal unless the native says accepted:false.
expect(refusal('EXECUTION_TRIGGER_BLOCKED', true)).toBeNull();
expect(refusal('EXECUTION_TRIGGER_BLOCKED', undefined)).toMatchObject({ result: 'paused' });
});

it('does not read node values: a production host offers no copy until T15-4m / T15-4w and C5', () => {
for (const platform of ['darwin', 'win32'] as const) {
const host = createComputerUseNativeHost(binding({ probe: () => ({}) }), platform);
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/computer-use-native-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,18 @@ function parseNativeBounds(value: unknown): ComputerUseNativeBounds {
};
}

/**
* The v2 interlock refusals a dispatch can still make on its own (#500 S5-c-2 PR-5): the native
* evaluates the interlocks again right before any input. Each is the dispatch code of a preflight
* refusal, so it takes the result of the same row of NATIVE_PREFLIGHT_REFUSALS: a block is rejected
* and an execution trigger is paused for a person. Shared with macOS (dispatchApproved).
*/
const NATIVE_DISPATCH_INTERLOCK_CODES: ReadonlySet<string> = new Set([
'SHELL_SURFACE_BLOCKED',
'EXECUTION_TRIGGER_BLOCKED',
'APPROVAL_NOT_ENABLED',
]);

export function classifyComputerUseNativeDispatchFailure(
error: unknown,
): ComputerUseNativeActionResult | null {
Expand Down Expand Up @@ -1495,9 +1507,16 @@ export function classifyComputerUseNativeDispatchFailure(
'INVALID_KEY',
'INVALID_TEXT',
'INVALID_TARGET',
...NATIVE_DISPATCH_INTERLOCK_CODES,
]);
if (candidate.accepted !== false && !preDispatchCodes.has(code)) return null;
if (!/^[A-Z][A-Z0-9_]{0,63}$/u.test(code)) return null;
if (NATIVE_DISPATCH_INTERLOCK_CODES.has(code)) {
const reasonCode = `native_${code.toLowerCase()}`;
const row = NATIVE_PREFLIGHT_REFUSALS.find((entry) => entry.reasonCode === reasonCode);
// The table owns the result; a code with no row stays a plain rejection.
return Object.freeze({ result: row?.deniedResult ?? 'rejected', reasonCode });
}
return Object.freeze({
result: 'rejected',
reasonCode: `native_${code.toLowerCase()}`.slice(0, 64),
Expand Down
Loading
Loading