mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(autopilot): carry the trigger owner's authorization on scheduled runs A schedule/webhook autopilot run left originator_user_id NULL, so canInvokeAgent saw no human and fail-closed against private agents. Arming a trigger is the same authorizing act as clicking "run now" — deferred, not absent — so withholding the originator did not remove that authority. It only forced every capability the automation legitimately needed to arrive as a separately-bounded borrow path: MUL-4857 for child creation, MUL-6691 for the run's own issues, #7902 for the accountable member's issues, with sub-issue dispatch queued up as the fourth. Each carries a hand-written scope that no operator can see in the UI. attribution.TriggerOwner / RuleOwner now populate UserID (the originator) instead of only the accountable side. finalizeAttribution mirrors it, so the originator == accountable invariant that migrations 190/197 enforce holds without a schema change or backfill. Both autopilot execution modes resolve through triggerOwnerAttribution, so run_only and create_issue are covered by the same two lines. This grants exactly the trigger owner's own rights — canInvokeAgent is untouched, only the principal handed to it changes — and originator_source still distinguishes a scheduled fire from a manual one in the audit trail. The borrowed-authority machinery in handler/agent_access.go is now dead for newly enqueued runs but is deliberately left in place: tasks enqueued by the previous backend are still running during a rolling deploy and carry originator NULL, and they must keep the path they were admitted under. Deleting it is a follow-up once this is deployed. Composio connected-app credentials also key on originator_user_id, so scheduled runs now receive the trigger owner's connected apps. Decoupling that from authorization was considered and explicitly deferred. Verification (real PostgreSQL 17): - internal/attribution, internal/service, internal/handler: PASS - the five attribution_stamp tests that pinned "originator stays NULL" were updated to assert the new principal, and fail against the old constructors - pre-existing, unrelated failures in internal/daemon/execenv (4 Hermes store-path tests) reproduce identically on a clean tree Co-authored-by: multica-agent <[email protected]> * refactor(autopilot): delete the borrowed-authority machinery With a scheduled run carrying its trigger owner as originator, every autopilot-shaped authority path in agent_access.go is unreachable: the invoke gate resolves a real human from the speaking task and judges it exactly as it judges that human acting directly. Removed: - autopilotTaskAssignAuthority + autopilotAssignAuthorityFromRequest (MUL-6691) - issueBoundToAutopilotTask, taskRunsAutopilotInWorkspace - autopilotDelegationAuthority and its FromRequest / FromComment resolvers (MUL-4857) - effectiveInvocationAuthorityFromRequest - assignAuthorityScope, assignScopeKind and the four scope constructors - commentTriggerComputeOptions.AutopilotDelegationAuthorityUserID + effectiveInvoker() validateAssigneePair and triggerTasksForComment lose their now-meaningless scope / delegation-authority parameters; every caller passes the run's originator instead. This also retires the fourth borrow scope #7902 proposed. CreateIssue still loads the parent issue when assigning, even though the gate no longer needs the row: it is what turns a cross-workspace or missing parent into 400 "parent issue not found" instead of a 403. Covered by a test. Two behaviour notes, both accepted: - Authority now propagates down a delegation chain, because the parent run finally has an originator to copy. Under the old design each hop had to re-borrow. This matches what a manual "run now" already did. - The old borrow path required task.Status == "running"; the originator path does not, and never did for any non-autopilot run. Task tokens are revoked at the terminal transition (TaskService, MUL-2600), so the primary guard remains; this only drops a second layer that existed solely for autopilot runs. Tests: the two suites that pinned the borrow bounds (autopilot-created-issue binding, authored-by-this-task binding) described a mechanism that no longer exists, so they collapse into one file asserting the rule that replaced them — rights / no rights / no human at all, across create-top-level, create-child, update and batch. The mention-authority suite keeps its shape; its fixture now gives the run a human, as a live run has. Verification (real PostgreSQL 17): internal/attribution, internal/service and internal/handler all PASS. Pre-existing unrelated failures in cmd/multica, internal/cli, internal/daemon and internal/daemon/execenv reproduce identically on a clean tree. Co-authored-by: multica-agent <[email protected]> * fix(autopilot): resolve one principal per dispatch and expire it with the run Addresses Elon's review of #7918. Three findings, all confirmed against the code before fixing. P1-1 — admission and the run's identity could be two different people. Automatic dispatch admitted the assignee agent as the AUTOPILOT CREATOR (canCreatorInvokeAgent) while the task took its originator from the trigger. With A owning both the autopilot and a private agent, and B owning the trigger, a dispatch was admitted as A and then executed as B — rights neither holds alone. The old suite could not see it because it called dispatchRunOnly directly, skipping admission. - autopilot_trigger gains an IMMUTABLE created_by (migration 449, best-effort backfill from published_by). published_by keeps its audit meaning and still transfers on a substantive edit; it is no longer an authorization input, because letting an editor move it silently re-authorizes the automation as that editor. Per Bohan's ruling the run always acts as the trigger CREATOR. - ResolveAutopilotTriggerPrincipal is now the single answer to "who does this run act as". Admission, the task stamp and every delegated run read it, so the fork is unrepresentable. It also binds the trigger to its autopilot (GetAutopilotTriggerForAutopilot — the old lookup was WHERE id = $1, which would let a foreign trigger select the principal) and re-checks workspace membership on every dispatch, so removing a member revokes their triggers. - canCreatorInvokeAgent is now unreachable and deleted. - RuleOwner reverts to accountable-only. It is a guess at who owns a rule; promoting it would hand a legacy trigger somebody's rights without that person arming anything. Such runs carry no originator and fail closed. P1-2 — a terminal task could still spend its human's rights. invokeOriginatorFromRequest now refuses completed / failed / cancelled tasks. Token revocation at the terminal transition stays the primary guard, but it is best-effort, explicitly non-fatal on failure, with a 24h expiry behind it. The check sits at the authorization boundary rather than the auth boundary, so a finishing run can still post its final comment but cannot start new work. P1-3 — the PR and code comments described Composio backwards. BuildTaskOverlay ignores the originator entirely and builds from the AGENT OWNER's connections and allow-list (MUL-3963). Naming the originator hands out no third-party credentials. Comments corrected; no decoupling was ever needed. Operational note: a trigger with neither created_by nor published_by (predating migration 186) resolves no principal, so its autopilot stops dispatching until the trigger is re-saved. That is the intended fail-closed behaviour. Tests: full dispatch through admission for both fork directions; principal fails closed on legacy / cross-autopilot / removed-member; terminal task refused on all three statuses; the edit-transfer tests now assert the principal STAYS with the creator while published_by moves. Verified the fork test fails when admission is reverted to canCreatorInvokeAgent. Verification (real PostgreSQL 17, pristine database): internal/attribution, internal/service, internal/handler and cmd/server all PASS. Migration 449 up/down round-trips. Pre-existing unrelated failures in cmd/multica, internal/cli, internal/daemon and internal/daemon/execenv are unchanged. Co-authored-by: multica-agent <[email protected]> * fix(autopilot): bind the trigger principal to its workspace, correct stale docs Second round of Elon's review on #7918. 1. The trigger lookup only enforced half of what its comment claimed. GetAutopilotTriggerForAutopilot matched on (trigger id, autopilot id); the workspace was used only for the creator's membership check afterwards. That check proves the resolved human belongs to the workspace PASSED IN, not that the autopilot does — a member of two workspaces satisfies it while the trigger came from the other tenant. The query now joins autopilot and filters on workspace_id, and the resolver passes it through. Regression test seeds two workspaces sharing one member and asserts the foreign workspace resolves nobody; verified it fails against the pre-fix query. 2. Several permission-boundary comments still described the pre-MUL-6951 model — that a run's authority and its accountable human transfer to whoever last edited the trigger. That is now the opposite of the code, and these are the comments a future reader would use to reason about the authorization model, so they are corrected at all four sites: the trigger create site, the SetAutopilotTriggerPublisher query, triggerOwnerAttribution, and the dispatchRunOnly attribution block. published_by is described as what it now is: a config-audit column with no bearing on a run. 3. The migration comment now states the backfill's imprecision plainly: for an already-edited trigger it freezes the last recoverable EDITOR as the immutable creator, not the historical creator, which the schema never recorded. It also records that a NULL/NULL trigger has no recovery path by decision (Bohan: leave them empty) — re-saving re-stamps published_by, not created_by — so its runs stay refused with a recorded failure_reason. Not done, per Bohan's ruling: no re-save takeover path for legacy triggers and no migration-period alerting. Verification (real PostgreSQL 17, pristine database): internal/attribution, internal/service, internal/handler and cmd/server PASS; full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). go vet ./internal/... and git diff --check clean. Co-authored-by: multica-agent <[email protected]> * docs(autopilot): carry the creator-principal semantics into every description Third round of Elon's review on #7918. Two must-fixes, no behaviour change. 1. The final semantics had not reached every surface that describes them. Migration 189 wrote COMMENT ON COLUMN text for autopilot_trigger.published_by_* saying it decides the accountable human of the runs a trigger fires. That is now false, and because sqlc generates column comments into models.go, the stalest description of the authorization model sat in generated code every reader passes through. 189 is released and untouched; migration 449 overrides both comments and documents created_by_* for the first time, and sqlc is regenerated. 449's down restores 189's original text verbatim so a rollback does not leave MUL-6951 wording describing a model the database no longer runs. The same stale semantics remained in prose at six code sites — the plain and manual dispatch entry points, the scheduled-plan path, the create_issue enqueue comment, the autopilot-level republish path, and the webhook trigger create site. All said a run's authority or accountable human follows the last editor, or that automation resolves rule_owner. These sit on the authorization path, so they are corrected rather than left to drift. published_by is now described as what it is: config audit, with no bearing on a run. Also corrects the backfill comment's migration reference: trigger publishers arrived in 189, not 186. 2. The test file this PR added open-coded INSERT ... RETURNING id with matching t.Cleanup(DELETE ...), which CLAUDE.md prohibits. Rewritten onto testutil.Fixture: a small principalFixture embeds it and every row — members, runtimes, private agents, autopilots, triggers, the second workspace — is built and cleaned up by dbfx. The only remaining DELETE is the mid-test member removal that IS the behaviour under test. Re-verified after the rewrite that both regressions still fail against the old code: reverting admission to the autopilot-creator check reddens the fork test, and dropping the workspace join reddens the cross-workspace test. Verification (real PostgreSQL 17, pristine database): full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). Migration 449 up/down round-trips and the down restores 189's comments. go vet ./internal/... and git diff --check clean. Co-authored-by: multica-agent <[email protected]> * docs(autopilot): state the backfill widening plainly, drop raw-SQL test seeding Fourth round of Elon's review on #7918. Both items are leftovers from the previous round; no behaviour change. 1. Migration 449's backfill comment carried two factual errors. It said published_by_* arrived in migration 186; it was 189. The previous round intended to fix this but the string replacement did not match the wrapped line and silently did nothing, so the wrong number shipped. It also claimed the backfill "is no wider than the authority those runs carry today", which is false: before MUL-6951 an automatic run had no originator at all, only narrowly-scoped borrow paths, so promoting a backfilled editor to the run's full originator IS a widening. The product decision to accept it stands; the comment now says so directly as a compatibility tradeoff instead of denying the widening. A migration comment that understates what it grants is worse than no comment. 2. The new test file still reached raw SQL transitively. It called seedAttributionFixture, which hand-writes INSERT and cleanup for its user / workspace / member / runtime / agent — so the file's own claim that "every row goes through testutil.Fixture" was untrue and the repository's fixture rule was still unmet. It now bootstraps the base identity with Fixture.User / Workspace / Member on an unbound fixture and adopts it, which also drops the agent and issue that helper builds and these tests never use. The comment explains why the helper is deliberately not reused. Re-verified after the fixture change that both regressions still fail against the old code: reverting admission to the autopilot-creator check reddens the fork test, and dropping the workspace join reddens the cross-workspace test. Verification (real PostgreSQL 17, pristine database): full suite shows only the four pre-existing unrelated failures (cmd/multica, internal/cli, internal/daemon, internal/daemon/execenv). go vet ./internal/..., gofmt and git diff --check clean. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
197 lines
7.1 KiB
Go
197 lines
7.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TestCreateIssuePositionTopOfColumn verifies that a newly created issue is
|
|
// placed above all existing issues in the same status column (manual sort order).
|
|
//
|
|
// Before the fix, new issues were always assigned position=0. After drag-reorder
|
|
// activity, existing issues accumulate negative positions at the top of the
|
|
// column, so a fresh issue at 0 would land in the middle of a long list.
|
|
//
|
|
// The fix queries MIN(position) for the workspace+status pair and assigns
|
|
// newPosition = minPos - 1, so the new ticket always appears first.
|
|
func TestCreateIssuePositionTopOfColumn(t *testing.T) {
|
|
// Create two issues via the API. The first lands at COALESCE(MIN,0)-1 = -1,
|
|
// the second at -2, and so on — each successive issue ends up above the
|
|
// previous one, which is exactly the desired behavior.
|
|
createIssueAndGetPosition := func(t *testing.T, title string) (string, float64) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "todo",
|
|
"priority": "low",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue %q: expected 201, got %d: %s", title, w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
return issue.ID, issue.Position
|
|
}
|
|
|
|
id1, pos1 := createIssueAndGetPosition(t, "position-test first issue")
|
|
t.Cleanup(func() { deleteTestIssue(t, id1) })
|
|
|
|
id2, pos2 := createIssueAndGetPosition(t, "position-test second issue")
|
|
t.Cleanup(func() { deleteTestIssue(t, id2) })
|
|
|
|
id3, pos3 := createIssueAndGetPosition(t, "position-test third issue")
|
|
t.Cleanup(func() { deleteTestIssue(t, id3) })
|
|
|
|
// Each new issue must have a strictly lower position than the previous one,
|
|
// ensuring it sorts to the top of the column in manual order.
|
|
if pos2 >= pos1 {
|
|
t.Errorf("second issue position (%v) should be less than first (%v)", pos2, pos1)
|
|
}
|
|
if pos3 >= pos2 {
|
|
t.Errorf("third issue position (%v) should be less than second (%v)", pos3, pos2)
|
|
}
|
|
}
|
|
|
|
// TestCreateIssuePositionBelowExplicitMinimum verifies the fix against a
|
|
// realistic drag-reordered column: after manually setting a low position
|
|
// directly in the DB (simulating drag-and-drop), a new issue created via the
|
|
// API should land below the explicit minimum, not at 0.
|
|
func TestCreateIssuePositionBelowExplicitMinimum(t *testing.T) {
|
|
// Create a seed issue via the API.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "position-seed issue",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("seed CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var seed IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&seed)
|
|
t.Cleanup(func() { deleteTestIssue(t, seed.ID) })
|
|
|
|
// Simulate drag-and-drop: overwrite the seed's position to a large negative
|
|
// value (-9999), as if the user dragged it to the very top of a long list.
|
|
const simulatedMinPos = -9999.0
|
|
if _, err := testPool.Exec(t.Context(),
|
|
`UPDATE issue SET position = $1 WHERE id = $2`,
|
|
simulatedMinPos, seed.ID,
|
|
); err != nil {
|
|
t.Fatalf("failed to set explicit position: %v", err)
|
|
}
|
|
|
|
// Now create a new issue. It must land below -9999, not at 0.
|
|
w2 := httptest.NewRecorder()
|
|
req2 := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "position-new issue",
|
|
"status": "todo",
|
|
"priority": "low",
|
|
})
|
|
testHandler.CreateIssue(w2, req2)
|
|
if w2.Code != http.StatusCreated {
|
|
t.Fatalf("new CreateIssue: expected 201, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
var newIssue IssueResponse
|
|
json.NewDecoder(w2.Body).Decode(&newIssue)
|
|
t.Cleanup(func() { deleteTestIssue(t, newIssue.ID) })
|
|
|
|
if newIssue.Position >= simulatedMinPos {
|
|
t.Errorf("new issue position (%v) should be less than simulated min (%v); got position 0 (unfixed behavior)?",
|
|
newIssue.Position, simulatedMinPos)
|
|
}
|
|
}
|
|
|
|
func TestAutopilotCreateIssuePositionBelowCurrentMinimum(t *testing.T) {
|
|
ctx := context.Background()
|
|
seedTitle := fmt.Sprintf("position-autopilot seed %d", time.Now().UnixNano())
|
|
autopilotIssueTitle := fmt.Sprintf("position-autopilot issue %d", time.Now().UnixNano())
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": seedTitle,
|
|
"status": "todo",
|
|
"priority": "low",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("seed CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var seed IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&seed)
|
|
t.Cleanup(func() { deleteTestIssue(t, seed.ID) })
|
|
|
|
const simulatedMinPos = -9999.0
|
|
if _, err := testPool.Exec(ctx,
|
|
`UPDATE issue SET position = $1 WHERE id = $2`,
|
|
simulatedMinPos, seed.ID,
|
|
); err != nil {
|
|
t.Fatalf("failed to set explicit position: %v", err)
|
|
}
|
|
|
|
var minBefore float64
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT MIN(position) FROM issue WHERE workspace_id = $1 AND status = 'todo'`,
|
|
testWorkspaceID,
|
|
).Scan(&minBefore); err != nil {
|
|
t.Fatalf("load min position: %v", err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Position autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"issue_title_template": autopilotIssueTitle,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilot.ID) })
|
|
|
|
queries := db.New(testPool)
|
|
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilot.ID))
|
|
if err != nil {
|
|
t.Fatalf("GetAutopilot: %v", err)
|
|
}
|
|
run, _, err := testHandler.AutopilotService.DispatchAutopilotManual(ctx, ap, pgtype.UUID{}, nil, parseUUID(testUserID))
|
|
if err != nil {
|
|
t.Fatalf("DispatchAutopilot: %v", err)
|
|
}
|
|
if run == nil || !run.IssueID.Valid {
|
|
t.Fatalf("dispatch run = %+v, want linked issue", run)
|
|
}
|
|
issueID := uuidToString(run.IssueID)
|
|
t.Cleanup(func() { deleteTestIssue(t, issueID) })
|
|
|
|
var createdPos float64
|
|
if err := testPool.QueryRow(ctx, `SELECT position FROM issue WHERE id = $1`, issueID).Scan(&createdPos); err != nil {
|
|
t.Fatalf("load autopilot-created issue position: %v", err)
|
|
}
|
|
if createdPos >= minBefore {
|
|
t.Errorf("autopilot-created issue position (%v) should be less than current min (%v); fixed position 0 would sort in the middle",
|
|
createdPos, minBefore)
|
|
}
|
|
}
|