Let a Bot work while nobody is watching - #18
Conversation
A Bot only ever acted because somebody typed to it. There was no way to say "every weekday at eight, check the overnight alerts and write me a summary", and no way for another system to hand a Bot a piece of work. The schema module already claimed to hold routines and neither table existed. A coworker that can only work while you watch is half a coworker. Routines are stored as a prompt and a schedule rather than as a script, because the thing being scheduled is a conversation turn: whatever the Bot would have done had somebody typed this at eight o'clock. That keeps a routine exactly as capable as the Bot is, and stops the table becoming a second, weaker way of describing work. The runner is the part that matters. A scheduled run happens server side with no browser in the loop, and every tool call still goes through the existing ComputerGateway. The browser executes the computer tools purely so it can render them, but the decision, the audit row and the action have always happened on the server, so an unattended run does not need a weaker path: it needs the same one and it gets it. What it does not get is the tools that ask a person for something, because there is no person; a model reaching for one is told so plainly rather than having the call silently dropped. An unattended run is exactly the condition a boundary exists for. PolicyContext gains `run.unattended`, always present and never optional, so a deployment can write `run.unattended && intent == "activate"` and let a routine read, browse and write notes while forbidding it to press anything. Present on every context because this engine reads an unevaluable expression as a match, so a field that was sometimes absent would turn a rule about routines into a rule that refuses every attended action too. `missed` is a real status rather than a nicety. A laptop asleep at eight o'clock did not do the eight o'clock work, and the two wrong answers are firing it at noon as though nothing had happened and recording nothing at all. The window is stamped with its own time, which is what makes recording a miss idempotent rather than a row a minute until the next window. One live run per routine is a partial unique index rather than a check in the scheduler. Two ticks overlap, two processes both tick, and the check-then-insert a careful loop would do has a gap in the middle; what fits through it is two emails sent. The second claim loses rather than races. Webhook deliveries arrive on their own Bun server on its own port, serving /health and /hooks/:endpointId and answering 404 to everything else. This is the one surface in the product meant to be reachable by a third party, and the way to keep the rest of the API away from it is for the rest of the API not to be on it. Secrets are bearer tokens shown once, stored as a SHA-256 and compared over digests so the comparison is fixed-width and constant-time. A new trigger keeps its first authenticated delivery as a sample and runs nothing until somebody has looked at what actually arrived, which is the gate that catches a mistyped hook before it starts real work.
The webhook page said the endpoint was "served on its own port, not on this one", which is the important half of the sentence and not the useful half. Somebody copying a path out of that page has no way to finish the URL, and the answer is a variable they have not read yet. It now names ROUTINE_WEBHOOK_PORT and says it defaults to one above the API's. The port itself is still not printed, because this page cannot know the address the listener is reachable at from outside: it binds to 127.0.0.1 and is normally behind something.
Review of the routines branch found nine places where the code and its own documentation disagreed. Every one of them is a promise a person would act on: a switch that stops unattended work, a ceiling on what a stranger may send, a sample somebody confirms, a run history that can be believed. They are fixed here together because they are the same kind of defect. ROUTINE_SCHEDULER=off now stops everything. It stopped the clock and left the webhook receiver listening, because the receiver was started whenever a computer was configured rather than when this deployment runs routines. That is exactly backwards for the case the switch exists for: somebody restores a production dump onto a laptop, the boot log says nothing fires on its own, and the restored triggers keep their endpoint ids and their secret hashes, so the sender that was already configured goes on driving Bots against real systems unattended. Meanwhile Run now, which is a signed-in person asking, correctly refused. One value now answers for the clock, Run now and the port alike. The public port refuses an oversized delivery before it is in memory. The ceiling was checked after `request.text()` had buffered the whole body, and Bun was never told a limit, so its own 128MB default was the real one: an unauthenticated caller could make the one internet-facing surface allocate a hundred megabytes per request. Bun is told the ceiling, the declared length is checked first so a sender gets this product's own sentence, and what arrived is measured in bytes rather than UTF-16 code units, which an emoji-heavy payload walks straight through. The sample a person confirms is the first delivery, not the last. Every delivery arriving before confirmation asks to be captured, and the column was overwritten each time, so the payload somebody read on the page was not the payload they confirmed a moment later — with a CI system posting every minute, reliably not. The database keeps the first non-null now, which also settles the race between two deliveries a second apart. The test that claimed this property only passed because it hand-set a value the receiver never sends; it now drives what the receiver actually does. A routine no longer records a miss for a window that predates it. Writing "every weekday at eight" at three in the afternoon produced, within a minute, a run reading "Missed, nothing was running" and an audit row agreeing with it, about a deployment that was running perfectly well. The decision takes the routine's own age, and a window before it is neither run nor recorded. Inside the grace period it still runs, because somebody writing that schedule at three minutes past eight has just said what they want. A run that never comes back no longer wedges its routine forever. Nothing bounded a run in wall-clock time: the turn cap counts turns that finish, and a stream that neither completes nor errors — an AG-UI Bot whose connection stalls — left the promise unsettled with no socket timeout underneath it. The row stayed `running`, held the one-live-run index, and the routine never fired again from the clock, from Run now or from a delivery, with deleting it and its history the only cure. Runs now have a deadline, and each tick closes out runs older than a ceiling no live run can reach, which is the same wedge arriving by a different road: a process killed mid-run. A webhook trigger carrying its own prompt is serialised. It has no run row, so the unique index that protects everything else had nothing to hold, and whoever had the secret could start one unattended agent run per delivery by retrying in a loop. One at a time per trigger, held in the process because there is no row to hold it, and the sender is told 409 rather than ignored. Triggers are an administrator's, and every administrator sees all of them. The routes admitted any signed-in person while the only page that renders them is behind the administrator guard, so an ordinary user could mint a publicly reachable endpoint and then had nowhere to see or revoke it, and an administrator reviewing this deployment's exposure was shown only what they had personally created. A trigger is a fact about the deployment rather than about whoever typed it. Confirming, changing and deleting one now leave audit rows, which is the moment an inert endpoint starts doing real work and the two moments a door closes, and a captured delivery is recorded as captured rather than as refused, which is the wrong word for the one outcome the feature is designed around. The routines list reads one run per routine. It read every run of every listed routine and kept the newest of each in JavaScript, on a page that refetches every fifteen seconds, against a table that grows by one row a day per routine forever. The database returns the newest of each now, which is the pattern the tick already used and the docblock beside it already argued for. Finally, `routine_runs.thread_id` says what it is. It was documented as the whole conversation including every tool call, and an unattended run speaks to the Bot directly rather than through the runtime that owns durable threads, so nothing writes the turns anywhere: the id names the conversation and correlates the run's own rows, and the docblock now says that and points at what a person actually gets, which is the summary and the audit trail.
Overlaps with
|
davidmckayv
left a comment
There was a problem hiding this comment.
Approving. Reviewed against the code and driven end to end locally.
Ran it, not just read it. Clean database, 744 pass / 0 fail. Then in the browser: created a routine, pressed Run now, and it completed with The page title "Example Domain" from example.com has been written into notes.md. The trail carried routine.run_started, two computer.action_allowed rows and routine.run_finished, so an unattended run really does go through the gateway.
The webhook auth is the strongest part. sha256 with timingSafeEqual over fixed-length digests so a length mismatch cannot throw, the secret shown once and stored only as a hash, a recognisable prefix so secret scanners catch a leak, content-length refused before the body is read, loopback by default, its own listener, and a new trigger holding its first delivery as a sample. I could not find a way in.
One thing to add before this is done. The audit row does not record that nobody was watching. run.unattended reaches the policy, but the payload is bot / actor / action / file / decision, and I confirmed zero rows from my live run contain it. That is the one fact this feature adds to the risk of an action, and an investigator reading the trail cannot currently tell a 3am unattended write from a supervised one without correlating timestamps against routine_runs. Given the product claim is that the trail says what happened, I would put unattended on the row.
Small UX thing: on the routine form the selected days are not visually distinct. I clicked Mon expecting to select it and actually deselected it from the weekday default, and could not tell from the screen which state I was in.
Latent, not a bug today: plugins/store.ts hardcodes run: { unattended: false }. Correct now, since the runner offers computer tools only, and your comment says so. The day routines get MCP, every run.unattended rule silently stops applying to MCP calls. Worth a comment pointing at that, since the failure is silent.
Merge-order note: this adds 0001_amusing_wild_child.sql, and #15 and #20 each add a different 0001. I reproduced the collision by accident: applying all three to one database left it with webhook_subscriptions but no routines, and /api/routines returning 500. Whichever lands first takes the number; the other two need renumbering and a regenerated journal.
Both sides added a required member to PolicyContext and a preset that reads it, so the policy now carries repeat.count and run.unattended together, the boundaries page offers a rule for each with its own cost line, and the gateway is built once with the repetition window folded in and shared with the unattended runner as before.
Migration numbering, for whoever mergesMain holds They do not conflict against main today, so each merges clean on its own — but the first one merged
|
…hook-triggers # Conflicts: # server/drizzle/meta/0001_snapshot.json # server/drizzle/meta/_journal.json # server/src/app.ts # server/src/config.ts # server/src/index.ts
|
Closing this, and the reason is about where OpenBot runs rather than about the code. OpenBot is meant to be deployed as several server processes behind a load balancer, serving a whole company, with people bringing their own agents to it. That is the target every feature has to hold up under. Any state that outlives a single request has to be shared, or the feature works on one box and quietly stops working the moment there are two, which is worse than not having it. Most of this branch already meets that bar. Two things do not meet it:
Both are solvable with what is already here: give the prompt-only path a row to claim like the routine path has, and mount the receiver on the main app rather than a second listener. Happy to take it again on that basis. We are also fixing our own side of this. The gateway's snapshot cache has the same problem and is load-bearing for the action boundary, and two merged changes are being reverted for the same reason. The rule is going on the PR template so it is stated up front rather than discovered at review. |
The problem
A Bot only ever acts because somebody typed to it. There is no way to say "every weekday at eight, check the overnight alerts and write me a summary", and no way for another system to hand a Bot a piece of work;
server/src/db/schema/coworker.tsclaimed in its docblock to hold routines and no such table existed.A scheduled run is also an unattended run: nobody can take the wheel, answer a question, or notice the wrong button before the consequence arrives. The boundary could not see that difference, so one set of rules had to cover both a supervised conversation and a browser acting on its own at three in the morning.
The approach
A routine stores a prompt and a schedule, not a script. The thing scheduled is a conversation turn, so a routine stays exactly as capable as the Bot is, and the schedule is JSON rather than cron so a form can edit it.
The runner has no privileges of its own. It drives the Bot's
run()server side with no browser in the loop and puts every tool call through the existingComputerGateway, which is where policy, audit and the action have always happened — the browser ran those tools only to render them — so an unattended run takes the same path and leaves the same rows. It does not offercomputer_request_help,computer_request_secretor the take-the-wheel handover, because a tool that waits ten minutes for an answer nobody will give is an unattended hang; a model reaching for one is told so rather than having the call dropped.run.unattendedis a required policy attribute, not an optional one. The CEL engine reads an expression it cannot evaluate as a match, so a sometimes-absent field would makerun.unattended && intent == "activate"refuse every attended action too. The/admin/boundariespreset names the cost of using it: a routine that may not press anything also cannot press a Next button on a page it was only going to read.A run is bounded twice, and a window nobody was awake for is recorded. Twenty turns stops a model re-snapshotting an unchanging page, a fifteen-minute deadline stops a stream that neither completes nor errors, and every tick closes out runs older than an hour left behind by a killed process. Without them a row stuck at
runningholds the one-live-run index — a partial unique index rather than a check in the scheduler — and the routine never fires again. A missed window is recorded, stamped with its own time so the record is idempotent; a window older than the routine is not, so aoncewritten for a moment already gone never fires.Deliveries arrive on a separate Bun server on its own port, which serves
/healthand/hooks/:endpointId, 404s everything else, and refuses an over-1MB body on the socket rather than after it is in memory.ROUTINE_SCHEDULER=offcloses the clock, Run now and this listener together, because a restored production dump keeps its endpoint ids and secret hashes. Three gates guard a delivery: a bearer secret shown once and stored only as a hash; verification, where a new trigger keeps its first authenticated delivery as a sample later ones do not replace, and runs nothing until somebody has looked at it; and an optional event-type allowlist. Triggers are the deployment's exposure rather than one person's work, so that surface requires an administrator and is not scoped to a creator; anybody else has to ask for one.What is not covered
routine_runsrow; its evidence is the audit trail. Those deliveries are serialised per trigger inside the process, so two server processes would each allow one.routine_runs.thread_idnames the run's conversation; nothing persists the turns, so a person gets the summary and the audit rows.PATCHroute supports all of it.AbstractAgentemitting AG-UI events.Merge notes
This branch changes a shared contract.
PolicyContextinserver/src/computer/policy.tsgainsrun: { unattended: boolean }, required rather than optional for the fail-closed reason above, so a branch that constructs a context should addrun: { unattended: false }rather than relax the field;policy-ask-a-personis the likeliest to meet this.server/src/audit.tsand the admin audit page are touched additively, andserver/src/index.tslifts theComputerGatewayinto a named const so the runner shares that instance rather than a second one.Verification
Gates ran against a dedicated database with the generated migration
server/drizzle/0001_amusing_wild_child.sqlapplied; no hand-written SQL.bun run formatandformat:check— cleanbun run lint— exit 0, with 24 pre-existing warnings in files this branch does not touchbun run typecheck— exit 0 across app, server and workerbun run test— 744 pass, 5 skip, 0 fail across 76 files. Main's baseline is 594, so this adds 150.bun run build— exit 0Seven new test files, each holding one thing down:
routine-schedule.test.ts— the arithmetic, including a window older than the routineroutine-scheduler.test.ts— the tick: a second claim losing, one bad routine not stopping the looproutine-runner.test.ts— tool calls reaching the gateway, both stream spellings, the cap, the deadlineroutine-store.integration.test.ts— the one-live-run index and the first sample surviving a later delivery, against a real databasewebhook-trigger.test.ts— the three gates, and the secret's hashing and comparisonroutine-receiver.test.ts— the public port over a real socket: size checks, 401/404/202/409routine-routes.test.ts— a routine scoped to its owner; triggers requiring an administratorBy hand: with
ROUTINE_SCHEDULER=offthe webhook port refuses connections while the API still answers; with it on, a 20MB unauthenticated POST is refused 413, a first delivery is captured and starts nothing, and after it is confirmed abuild.finisheddelivery produces a completed run whosecomputer_navigatecalls appear in the trail as ordinarycomputer.action_allowedrows.