dotbot_service
The DotBot bridge, minus its privileges.
src/lua/trusted/dotbot/ is the layer between the DotBot panel (user-VM Luau,
untrusted) and the DotBot plugin (trusted scope, holds a person's ChatGPT /
Claude sign-in). Everything in that layer that is a rule rather than a
capability lives in this module: the job table, the consent choreography,
the event ring, the mirror the read-only exports answer from, and the scrub
every value passes through on its way out.
local Service = require("@builtin::modules.dotbot_service")
local service = Service.new({
plugin = function() return rawget(_G, "dotbot") end,
await = task.await,
spawn = task.spawn,
consentRequest = function(o) return consent.request(o) end,
consentGranted = function(o) return consent.granted(o) end,
openUrl = function(url) return system.openUrl(url) end,
newJobId = function() return __crypto.uuid_v4() end,
nowMs = function() return os.clock() * 1000 end,
})
Why the split
It is what makes the layer testable. The module is a factory: it holds no
state of its own and no privileged global, so a test can construct an instance
over fakes and drive every rule in it. Being a builtin module, a world script
can construct one too — and wins nothing, because its instance talks to its own
fakes. The one instance that talks to the real consent gate, the real plugin
namespace and the real system.openUrl is built in the trusted VM, out of
globals only the trusted VM holds, and is reachable only as
trusted.call("dotbot.<method>", …).
The shape, and where it comes from
| Constraint | Consequence |
|---|---|
A trusted.call handler must not yield (trusted/exports.luau) | Nothing that awaits is answered in the frame. Awaiting verbs are start/poll jobs; reads are answered from a mirror a spawned task refreshes. |
| There is no push from the trusted VM into a user VM | The panel drains a cursor (events(cursor)) rather than registering a callback — so no trusted code ever calls into a user VM, and a world script cannot install itself as the panel's event handler. |
stop is the one verb answered in the frame: stopping is always allowed and
always cheap, and a stop that needed a confirmation would be a stop that lands
after the turn it was meant to end. Its outcome arrives as a turn event.
Signing in
A person's plan is reached by one of two flows, and the plugin says which.
connectStart reads it from the plugin's own account state (M.signInFlow)
and, when a logged-out account names none, asks the plugin — never the caller,
whose choice of flow would be a choice of which endpoint the person is sent to.
| Flow | connect.start answers | Then | Finished by |
|---|---|---|---|
deviceCode (ChatGPT) | { flow = "deviceCode", state = { userCode, verificationUrl, … } } | the person types the code on the provider's page | checkSignIn until the account connects |
pasteCode (Claude) | { flow = "pasteCode", state = { authorizeUrl } } | the person signs in and the provider shows a code | completeSignIn(provider, pasted) |
cancelSignIn abandons either.
Only connect asks for a confirmation. The three that continue it do not, for
stop's reason: they carry out a connect the person has already confirmed and
is at that moment completing on the provider's own page, they spend nothing, and
completeSignIn cannot connect anything on its own — the plugin holds a
single-use verifier from the gated connect and answers "no pending login" to
anything else. A second dialog inside one flow is how a person learns to stop
reading the first.
Spending
sendStart and commandStart both ask for the spend grant, keyed by action,
plugin and the agent's display name, remembered for the world session. A slash
command is gated because a slash command can run a turn — /compact summarises
a conversation with the model — so it spends the plan exactly as a message does.
What it will not do
- Return a token, a credential id or a header.
M.scrubdrops every credential-named key at every depth, on every value leaving the layer — results and event payloads alike. It is the second line: DotBot's account and event types carry no token by construction. The names it drops are exact, not a substring match ontoken, so the usage counters a person is shown (tokens) and a turn'sturnIdsurvive. - Take a URL.
openSignInStart(provider)reads the verification URL out of the plugin's own account state for the provider named, checks it is an http(s) page, shows a person its host in the dialog, and only then opens it. Account state naming none errors the job. - Answer, dismiss or count a consent dialog. There is nothing here that settles one; the gate is the engine's, and only a person at the keyboard answers it.
- Put a caller's own words on a dialog.
detailis resolved through the plugin's own agent and account lists and the flow errors when it cannot be named rather than falling back to the id it was handed. The two arguments a caller does supply beyond a message — a pasted sign-in code and a command line — are shape-checked (M.pastedCode,M.commandLine) and reach no dialog: the pasted code's flow draws none, and a command's dialog names the agent. - Read a plugin's refusal as an answer. The plugin host reports one by
resolving the promise with
__plugin_errorrather than raising, socallchecks for it; otherwise a job settlesdoneon a sign-in that never happened.
Constants
| Name | Meaning |
|---|---|
RING_CAPACITY | 512 — events held. A cursor older than the tail is answered dropped = true. |
JOB_LIMIT | 8 — jobs live at once, across every caller. A ninth start is { ok = false, reason = "busy" }. |
JOB_TTL_MS | 600000 — a job nobody polls is dropped after ten minutes. |
STALE_MS | 2000 — how long a mirrored read stays current before the next read kicks a refresh. |
PLUGIN_NAME | "dotbot" — the name the consent gate resolves the dialog's display name from. |
DEVICE_CODE / PASTE_CODE | "deviceCode" / "pasteCode" — the two sign-in flows, as connect.start names them back. |
Tested by src/lua/lib/tests/suites/dotbot_exports.testSuite.
Scoped to this part · feeds back into the world's score.