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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A request for a secret no longer follows a Bot into tomorrow's conversations

An unanswered ask to take the wheel stops being shown after ten minutes, because control belongs to a
Bot's computer rather than to a conversation. The other prompt on that computer, the masked box a Bot
opens when it needs one value it must not be told, was never given the same treatment: it sat there
indefinitely, so every later conversation with that Bot was flagged as needing a person and showed a
request for a password, captioned with a label written for whoever asked half a day earlier. It now
expires on the same ten-minute window, and stops being answerable at the moment it stops being shown,
so a value typed into a box left open in an old tab is refused rather than sent to a page whose run
has ended. A request inside the window is unchanged, and a person actually holding the wheel is still
never timed out.
### `COMPUTER_BROWSER_IDLE_MS=0` now keeps browsers resident, as it says it does

Zero is the documented way to switch off the sweep that closes a Bot's browser after it has sat
Expand Down
67 changes: 66 additions & 1 deletion agent-computer/src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ export const NO_SECRET_PENDING = "Nothing is waiting for a secret.";
*/
export const HELP_REQUEST_TTL_MS = 10 * 60 * 1000;

/**
* How long an unanswered request for a secret is shown for.
*
* The same window as the ask above, for the same reason, and named separately because they are two
* different prompts and shortening one should not silently shorten the other. A secret request is
* the narrower of the two — it names a field on a page — so nothing about it survives the run that
* made it any better than a request to take the wheel does.
*/
export const SECRET_REQUEST_TTL_MS = HELP_REQUEST_TTL_MS;

export const HUMAN_HAS_CONTROL =
"A person has control of the computer right now. Wait for them to hand it back before acting.";
export const TAKE_CONTROL_FIRST =
Expand All @@ -97,6 +107,48 @@ export function createControl(
requested: false,
};

/**
* When the Bot asked for a secret, so an unanswered request can stop being shown.
*
* Held here rather than on the state because nothing outside needs it: the surface renders the
* label and the field, and a timestamp added to the published state would be one more thing on a
* screen that is asking somebody for a password.
*/
let secretRequestedAt: string | undefined;

/**
* Drop a secret request the run that made it has outlived.
*
* The same argument as the ask above, missed for the other half of it. Control belongs to the
* computer rather than to a conversation, so a request nobody answered sat on it for ever: the run
* that asked had ended, and every later conversation with that Bot still showed a masked box
* wanting "the six-digit code from your authenticator", written for whoever asked and rendered to
* whoever looked. The surface makes no distinction — `useNeedsYou` lights the same "needs you" on
* `requested` and on `secretWanted` — so expiring one and not the other left the Bot flagged
* anyway.
*
* Expired on read for the same reason the ask is: there is nothing to wake, and the only thing
* that cares is whoever looks next. Read by `pendingSecret` too, because that is what decides
* whether a value typed now is accepted, and a prompt that has stopped being shown must not still
* be answerable.
*/
function dropStaleSecret(): void {
if (!state.secretWanted || !secretRequestedAt) return;
if (
Date.parse(now()) - Date.parse(secretRequestedAt) <=
SECRET_REQUEST_TTL_MS
) {
return;
}
secretRequestedAt = undefined;
state = {
...state,
secretWanted: undefined,
secretRef: undefined,
secretSnapshotId: undefined,
};
}

return {
/**
* The current state, as the surface polls it. A copy, so a caller cannot mutate the machine.
Expand All @@ -105,7 +157,7 @@ export function createControl(
* expired on read rather than on a timer because there is nothing to wake: the run that asked
* has ended, and the only thing that cares is whoever looks next.
*
* Only ever the ASK. A person actually holding the wheel is never timed out from under them:
* Only ever an ASK. A person actually holding the wheel is never timed out from under them:
* they may be halfway through typing a code, and taking the browser back mid-sign-in is worse
* than any stale prompt.
*/
Expand All @@ -119,6 +171,7 @@ export function createControl(
const { reason: _reason, requestedAt: _at, ...rest } = state;
state = { ...rest, requested: false };
}
dropStaleSecret();
return { ...state };
},

Expand Down Expand Up @@ -152,6 +205,7 @@ export function createControl(
"Say which field the value goes in, using a ref from your snapshot.",
);
}
secretRequestedAt = now();
state = {
...state,
secretWanted:
Expand All @@ -170,8 +224,13 @@ export function createControl(
*
* Read before typing so the caller can refuse when nothing asked for one: this is what keeps the
* masked box from being a general-purpose way to type into the page.
*
* Which is also why the staleness check is here and not only on `get`: a request that has stopped
* being shown must stop being answerable at the same moment, or a value typed into a box left
* open in an old tab still goes to a page whose run ended.
*/
pendingSecret(): { ref: string; snapshotId?: number } | null {
dropStaleSecret();
if (!state.secretWanted || !state.secretRef) return null;
return { ref: state.secretRef, snapshotId: state.secretSnapshotId };
},
Expand All @@ -183,6 +242,7 @@ export function createControl(
* can try again.
*/
secretSupplied(): void {
secretRequestedAt = undefined;
state = {
...state,
secretWanted: undefined,
Expand All @@ -199,6 +259,9 @@ export function createControl(
* box left open behind them no longer corresponds to an active request.
*/
take(): ControlState {
// With the pending secret, since the state below drops it: the timestamp is what says one is
// outstanding, and leaving it behind a request that is gone is how a stale one comes back.
secretRequestedAt = undefined;
state = {
holder: "human",
since: now(),
Expand All @@ -217,6 +280,8 @@ export function createControl(
* secret box left open afterwards is asking for a password nothing is waiting for.
*/
release(): ControlState {
// As above: the request the state below drops takes its timestamp with it.
secretRequestedAt = undefined;
state = {
holder: "bot",
since: now(),
Expand Down
79 changes: 79 additions & 0 deletions agent-computer/tests/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,82 @@ describe("an unanswered request to take the wheel", () => {
expect(control.get().holder).toBe("human");
});
});

/**
* The other half of the same request, which did not expire at all.
*
* A request for a secret is an ask like the one above and outlived its run the same way: the label
* the Bot wrote is rendered to whoever looks next, and the surface makes no distinction between the
* two — `useNeedsYou` lights the same "needs you" on `requested` and on `secretWanted` — so timing
* one out and not the other left the Bot flagged for a conversation that ended anyway, now asking
* for a password rather than for a hand.
*
* It is also the prompt where being stale matters more. Answering it types a value into a field
* named by a ref from a snapshot the browser has long since moved past, so the person is being asked
* for their password by a request nothing is waiting for.
*/
describe("an unanswered request for a secret", () => {
test("is still shown, and still answerable, inside the window", () => {
let clock = "2026-08-22T03:00:00.000Z";
const control = createControl(() => clock);
control.requestSecret({ ref: "e12", label: "the six-digit code" });

clock = "2026-08-22T03:05:00.000Z";
expect(control.get().secretWanted).toBe("the six-digit code");
expect(control.pendingSecret()).toEqual({
ref: "e12",
snapshotId: undefined,
});
});

test("stops being shown once it is stale, and takes the field it named with it", () => {
let clock = "2026-08-22T03:00:00.000Z";
const control = createControl(() => clock);
control.requestSecret({
ref: "e12",
label: "the six-digit code",
snapshotId: 4,
});

clock = "2026-08-22T03:20:00.000Z";
const state = control.get();
// The label is the part that was being rendered to whoever looked, so it goes, and the field it
// named goes with it: half a request is not a thing anything downstream knows how to read.
expect(state.secretWanted).toBeUndefined();
expect(state.secretRef).toBeUndefined();
expect(state.secretSnapshotId).toBeUndefined();
});

test("stops being answerable at the same moment it stops being shown", () => {
/*
* Asked through `pendingSecret` alone, without a `get` first. That is the call `/human/secret`
* makes before it types, and it is the one that decides whether a value supplied now reaches the
* page: expiring only on the path the surface polls would leave a prompt that is no longer
* displayed still able to accept a password.
*/
let clock = "2026-08-22T03:00:00.000Z";
const control = createControl(() => clock);
control.requestSecret({ ref: "e12", label: "the six-digit code" });

clock = "2026-08-22T03:20:00.000Z";
expect(control.pendingSecret()).toBeNull();
});

test("a fresh request after a stale one is shown, not swallowed by it", () => {
// The expiry must clear its own bookkeeping, or the next request inherits the old timestamp and
// is stale on arrival: a Bot that asked twice would be answerable neither time.
let clock = "2026-08-22T03:00:00.000Z";
const control = createControl(() => clock);
control.requestSecret({ ref: "e12", label: "the six-digit code" });

clock = "2026-08-22T03:20:00.000Z";
expect(control.pendingSecret()).toBeNull();

control.requestSecret({ ref: "e40", label: "the code, again" });
expect(control.get().secretWanted).toBe("the code, again");
expect(control.pendingSecret()).toEqual({
ref: "e40",
snapshotId: undefined,
});
});
});