diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e343250a..f1236b161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/agent-computer/src/control.ts b/agent-computer/src/control.ts index d74913baa..dc8922869 100644 --- a/agent-computer/src/control.ts +++ b/agent-computer/src/control.ts @@ -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 = @@ -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. @@ -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. */ @@ -119,6 +171,7 @@ export function createControl( const { reason: _reason, requestedAt: _at, ...rest } = state; state = { ...rest, requested: false }; } + dropStaleSecret(); return { ...state }; }, @@ -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: @@ -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 }; }, @@ -183,6 +242,7 @@ export function createControl( * can try again. */ secretSupplied(): void { + secretRequestedAt = undefined; state = { ...state, secretWanted: undefined, @@ -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(), @@ -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(), diff --git a/agent-computer/tests/control.test.ts b/agent-computer/tests/control.test.ts index bbcc3f094..afaacbdf8 100644 --- a/agent-computer/tests/control.test.ts +++ b/agent-computer/tests/control.test.ts @@ -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, + }); + }); +});