* fix(autopilot): let an agent trigger a manual run for the human it acts for
MUL-6951 replaced canCreatorInvokeAgent, which judged automatic dispatch by the
autopilot CREATOR and needed no trigger, with ResolveAutopilotTriggerPrincipal,
which resolves the FIRING TRIGGER's creator. The manual entry point passes no
trigger by construction, so the resolver returned nothing on its first line and
every manual trigger from a non-member actor was refused. Scheduled runs kept
working — they have a trigger row — which is what made this read as a
configuration problem rather than a regression. Reported as #8078.
The refusal was also self-contradicting: requireAutopilotWrite had already
resolved a real human from the request (for a task token, the runtime owner the
middleware binds) and confirmed they may trigger this autopilot. Three lines
later memberActorUserID discarded that human for not being a "member" actor, and
the empty identity fell into the automation branch.
TriggerAutopilot now resolves the human the caller acts FOR:
- a member acts for themselves, judged exactly as before;
- an agent acts for its run's ORIGINATOR, which is how every other invoke
surface judges an agent actor (MUL-3963 / canInvokeAgent), including this
file's own private-leader gate on save.
Reading the runtime owner instead would have been a smaller diff and the wrong
grant: it makes every agent a standing proxy for its machine's owner, so anyone
who can talk to the agent inherits that owner's autopilot rights. The originator
must ALSO hold write access, so an agent can only press a "Run now" that the
human ordering it could press themselves.
No resolvable human is refused outright (403). The alternative was the
workspace-broad exception invokeAgentDecision grants unattributed agent/system
principals on a public_to-workspace agent, which would have admitted an
originator-less chain here too. Rejected per Bohan: a manual trigger is by
definition somebody's decision, so a chain arriving with no human at its top
means something upstream dropped it, and that should surface.
Two things this does NOT change, both deliberate:
- requireAutopilotWrite still judges the request's authenticated user. That it
resolves the runtime owner for an agent is pre-existing and shared by all
eight autopilot write endpoints; narrowing it is a separate change with its
own blast radius, filed separately.
- The run's attribution stays direct_human to the resolved human rather than
delegation. attributionForIssueTask treats a valid actor as direct_human by
contract, shared with assign/promote/rerun, so labelling this one path
delegation means threading a new parameter through TaskService's enqueue
chain. The human on the hook is correct either way; only the label is
coarser. Filed as follow-up rather than half-applied to one execution mode.
Two independent defects the same report exposed:
- The skip reason blamed "the trigger records no owner" on a path that has no
trigger, sending #8078's reporter hunting a broken trigger owner for a day.
It now branches on whether a trigger exists.
- `multica autopilot trigger` printed "Autopilot triggered" and exited 0 for a
skipped run, so the operator — and any agent running it for them — read
success. It now mirrors the web client's runNowToastKind whitelist: only
issue_created and running are success, everything else prints failure_reason
and exits non-zero. --output json still writes the run to stdout first.
This is the half that made the bug silent, and it covers every skip reason,
not just this one.
The MUL-4525 admission test asserted that (no actor, no trigger) must be refused
and called it "automation dispatch" — but that is exactly the argument pair a
manual trigger produced, so the test pinned the regression and would have
blocked the fix. Rewritten to assert the no-principal phrasing and, explicitly,
that the reason does not blame a trigger row that does not exist.
Verification (real PostgreSQL 17): new handler suite covers agent-with-
originator (dispatches), agent-without-originator (403, no run row), originator
lacking write (403, no run row) and the untouched member path. The acceptance
test was confirmed to fail against the pre-fix handler with exactly the reported
symptom — status skipped, invocation_not_allowed. internal/service and
internal/handler pass; the one internal/handler failure (TestPluginRoutes-
RequireWorkspaceAdmin, a duplicate user email) and the cmd/multica failures
reproduce identically on a clean tree.
Refs #8078
Co-authored-by: multica-agent <[email protected]>
* fix(autopilot): judge a manual trigger by the ordering human alone
Addresses Elon's review of #8099. Two must-fixes, both confirmed against the
code before changing it.
Must-fix 1 — the endpoint still required TWO humans to hold the grant.
requireAutopilotWrite ran in front of the originator check, and under a task
token it judges the RUNTIME OWNER. So the effective rule was "runtime owner AND
originator", not "the human who ordered it" — the opposite failure from the one
#8078 reported, and it broke a case a member is plainly entitled to: U created
the autopilot and may invoke an agent running on O's machine, O holds no grant,
so U could press Run now themselves but could not ask the agent to. It also
contradicted the docs this PR shipped, which already promised originator-based
authorization.
requireAutopilotTriggerInvoker is now this endpoint's only write gate.
requireAutopilotWrite is not called alongside it. Workspace tenancy for the
authenticated caller is unaffected: /api/autopilots sits inside the router's
RequireWorkspaceMember group, so dropping the second check loses no isolation —
it only stops a third party's machine ownership from vetoing the ordering
human's own rights. The other seven autopilot write endpoints are untouched and
stay with MUL-7108.
Authorization also moved ahead of the status check, so an unauthorized caller
cannot tell an active autopilot from a paused one.
The positive test could not have caught this: it let testUserID be runtime
owner, autopilot creator and originator at once. TestTriggerAutopilot_
RuntimeOwnerNeedsNoGrant now separates all three — the ordering human creates
the autopilot, the runtime owner is a plain member with no grant — and was
verified to fail against the previous commit with exactly the 403 Elon
described.
Must-fix 2 — the actionable refusal never reached the person running the
command. The handler named the cause, but FormatError collapses every 403 into
generic "no access" copy, so `calling run records no originator` died in the
response body. That is the same silence #8078 was about, one layer up.
Both refusals now carry a stable code (autopilot_trigger_no_originator,
autopilot_trigger_forbidden) via writeErrorCode, and cli.ServerErrorCode reads
it. The trigger command opts those two out of the generic copy and prints
guidance. The branch is on the code, never on the English sentence: copy edits
and translation must not silently disable it. The opt-out is per-code, so every
403 this command has not named keeps the deliberately vague copy that stops a
refusal confirming a resource exists — pinned by a test.
The two 403s deliberately share one sentence and one code whether the ordering
human is a non-member or merely ungranted, so the endpoint cannot be used to
probe workspace membership.
Verification (real PostgreSQL 17):
- TestTriggerAutopilot_RuntimeOwnerNeedsNoGrant fails against the previous
commit's handler and passes now; the other four trigger tests still pass.
- Both 403 tests assert the code, not just the status — the CLI's output
depends on it.
- New cmd/multica test runs the real cli.FormatError over both refusals, since
the defect being guarded lives in FormatError's collapse, and asserts an
un-opted-in 403 still gets the generic copy.
- New internal/cli TestServerErrorCode covers missing code, non-JSON, malformed
JSON, prose in `code`, and a non-HTTP error.
- internal/service passes. Pre-existing unrelated failures reproduce identically
on a clean tree: TestPluginRoutesRequireWorkspaceAdmin and
TestCommentSourceContextLifecycle in internal/handler, four TestCLIConfig_*
in internal/cli.
- gofmt, go vet, git diff --check clean.
Docs corrected at both sites to state the contract explicitly: the machine
owner's grants are not consulted, only workspace membership.
Refs #8078
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>