fix: claim a run before the form mints its secrets, not after

The claim sat in `runAction`, which `RunForm.run()` reaches only after
`processSecretArgs` has written a variable per `password` argument. So a press
that lost the race still minted — and worse, it read the pending call after
that round trip, by which point the winner had settled it: with no call to
claim, the losing press started a job of its own.

`runRefusal` already documents this boundary, saying a caller cannot gate in
`runAction` because it comes after the mint. The claim belongs on the same side,
so the form takes it through `claimRun` just before the first write and hands it
back through `releaseRun` when the mint fails. The pages keep the call they
claimed rather than re-reading it, so a claimed press either answers that call
or does nothing.

The chat card's own form already worked this way, taking the claim ahead of its
`processSecretArgs`; the page was the asymmetric surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 07:42:13 -07:00
co-authored by Claude Opus 5
parent e5e741d97d
commit 6c2ad4bbf6
3 changed files with 83 additions and 14 deletions
@@ -91,6 +91,10 @@
if (blockedByUnparseable) {
return
}
// Last gate before the first write: everything below either mints or sends.
if (claimRun && !claimRun()) {
return
}
let processedArgs: Record<string, any>
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
if (resetKeys.length > 0) {
@@ -99,6 +103,7 @@
try {
processedArgs = await processSecretArgs(withDefaults, runnable?.schema, $operatingWorkspace)
} catch (e) {
releaseRun?.()
sendUserToast('Failed to process sensitive args: ' + e, true)
return
}
@@ -143,6 +148,18 @@
* for JSON or back mounts a `password` widget, which mints from the literal it opens
* on regardless of being disabled. */
runRefusal?: string
/** Take whatever this press has to own before anything is written, and return false to
* drop it. Behind the same boundary as `runRefusal` and for the same reason: the
* secret processing mints a variable per `password` argument, so a claim made in
* `runAction` comes after that write. For a caller whose run can be started from a
* second surface — the chat card holding the same tool call — where one press winning
* has to stop the other from minting, not just from starting a job. Called once per
* press that reaches the mint, and says itself why a press was dropped. */
claimRun?: () => boolean
/** Hand back what `claimRun` took, for a press that claimed and then never reached
* `runAction` — the secret processing in between can still fail. A press that does
* reach it releases through the caller's own start path instead. */
releaseRun?: () => void
runAction: (
scheduledForStr: string | undefined,
args: Record<string, any>,
@@ -177,6 +194,8 @@
runnable,
runAction,
runRefusal = undefined,
claimRun = undefined,
releaseRun = undefined,
buttonText = 'Run',
schedulable = true,
detailed = true,
@@ -290,6 +290,30 @@
}
}
/** Held between `claimRun` and `runFlow`: the form mints its `password` arguments in
* between, so the claim has to be taken before that write while the job is started after
* it. Cleared as it is used, so a press that never claimed cannot inherit it. */
let claimedCall: PendingRun | undefined = undefined
/** One call is one job, and the chat card's form is live alongside this page's: the first
* of the two to claim owns the call, and the other stops here — before it mints a
* variable per `password` argument for a run it will not start. */
function claimRun(): boolean {
const call = pendingRun
if (call && !call.beginSubmit()) {
sendUserToast('This run is already being started from the chat.', true)
return false
}
claimedCall = call
return true
}
/** The form claimed and then never reached the run — its secret processing failed. */
function releaseRun() {
claimedCall?.endSubmit()
claimedCall = undefined
}
async function runFlow(
scheduledForStr: string | undefined,
args: Record<string, any>,
@@ -299,13 +323,12 @@
// Taken before the round trip below: the call this press answers is the one it was
// pressed under, and a call settled meanwhile — declined, then another proposed for
// this same flow — must not be answered with a job it never asked for.
const call = pendingRun
// One call is one job, and the card's form is live alongside this one: without the
// claim, a Run there during the round trip below starts a second.
if (call && !call.beginSubmit()) {
sendUserToast('This run is already being started from the chat.', true)
return
}
// The call this press claimed, not whatever is pending now: the form mints its secrets
// between the claim and here, and a call that settled across that round trip would
// leave this press looking like a page run of its own — starting a second job for a
// call the chat has already answered.
const call = claimedCall
claimedCall = undefined
loading = true
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
try {
@@ -930,6 +953,8 @@
bind:isValid
runnable={flow}
runAction={runFlow}
{claimRun}
{releaseRun}
runRefusal={pendingRun?.planModeActive
? PLAN_MODE_MESSAGES.runFormRefused
: undefined}
@@ -379,6 +379,30 @@
let isValid = $state(true)
let runLoading = $state(false)
/** Held between `claimRun` and `runScript`: the form mints its `password` arguments in
* between, so the claim has to be taken before that write while the job is started after
* it. Cleared as it is used, so a press that never claimed cannot inherit it. */
let claimedCall: PendingRun | undefined = undefined
/** One call is one job, and the chat card's form is live alongside this page's: the first
* of the two to claim owns the call, and the other stops here — before it mints a
* variable per `password` argument for a run it will not start. */
function claimRun(): boolean {
const call = pendingRun
if (call && !call.beginSubmit()) {
sendUserToast('This run is already being started from the chat.', true)
return false
}
claimedCall = call
return true
}
/** The form claimed and then never reached the run — its secret processing failed. */
function releaseRun() {
claimedCall?.endSubmit()
claimedCall = undefined
}
async function runScript(
scheduledForStr: string | undefined,
args: Record<string, any>,
@@ -388,13 +412,12 @@
// Taken before the round trip below: the call this press answers is the one it was
// pressed under, and a call settled meanwhile — declined, then another proposed for
// this same script — must not be answered with a job it never asked for.
const call = pendingRun
// One call is one job, and the card's form is live alongside this one: without the
// claim, a Run there during the round trip below starts a second.
if (call && !call.beginSubmit()) {
sendUserToast('This run is already being started from the chat.', true)
return
}
// The call this press claimed, not whatever is pending now: the form mints its secrets
// between the claim and here, and a call that settled across that round trip would
// leave this press looking like a page run of its own — starting a second job for a
// call the chat has already answered.
const call = claimedCall
claimedCall = undefined
try {
runLoading = true
const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined
@@ -1145,6 +1168,8 @@
bind:isValid
runnable={script}
runAction={runScript}
{claimRun}
{releaseRun}
runRefusal={pendingRun?.planModeActive
? PLAN_MODE_MESSAGES.runFormRefused
: undefined}