diff --git a/server/internal/daemon/channel_identity_env_test.go b/server/internal/daemon/channel_identity_env_test.go new file mode 100644 index 0000000000..7d5c0739c4 --- /dev/null +++ b/server/internal/daemon/channel_identity_env_test.go @@ -0,0 +1,186 @@ +package daemon + +import ( + "io" + "log/slog" + "testing" + + "github.com/multica-ai/multica/server/internal/daemon/execenv" +) + +func TestChannelIdentityEnvironment(t *testing.T) { + t.Run("Feishu adds generic metadata and Lark alias", func(t *testing.T) { + task := Task{ + ChatSessionID: "chat-1", + ChatChannelType: execenv.ChannelTypeFeishu, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeFeishu, + InstallationID: "11111111-1111-1111-1111-111111111111", + ChannelUserID: "ou_sender", + }, + } + got, reason := channelIdentityEnvironment(task) + if reason != "" { + t.Fatalf("channelIdentityEnvironment reason = %q", reason) + } + want := map[string]string{ + channelTypeEnv: execenv.ChannelTypeFeishu, + channelInstallationIDEnv: "11111111-1111-1111-1111-111111111111", + channelUserIDEnv: "ou_sender", + larkOpenIDEnv: "ou_sender", + } + if len(got) != len(want) { + t.Fatalf("environment = %#v, want %#v", got, want) + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } + }) + + t.Run("Slack gets generic metadata without Lark alias", func(t *testing.T) { + task := Task{ + ChatSessionID: "chat-2", + ChatChannelType: execenv.ChannelTypeSlack, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeSlack, + InstallationID: "22222222-2222-2222-2222-222222222222", + ChannelUserID: "U_SENDER", + }, + } + got, reason := channelIdentityEnvironment(task) + if reason != "" { + t.Fatalf("channelIdentityEnvironment reason = %q", reason) + } + if got[channelTypeEnv] != execenv.ChannelTypeSlack || + got[channelInstallationIDEnv] != "22222222-2222-2222-2222-222222222222" || + got[channelUserIDEnv] != "U_SENDER" { + t.Fatalf("environment = %#v", got) + } + if _, ok := got[larkOpenIDEnv]; ok { + t.Fatalf("%s must not be set for Slack: %#v", larkOpenIDEnv, got) + } + }) + + t.Run("missing optional payload is backward compatible", func(t *testing.T) { + got, reason := channelIdentityEnvironment(Task{ChatSessionID: "chat-3", ChatChannelType: execenv.ChannelTypeFeishu}) + if reason != "" || got != nil { + t.Fatalf("environment = %#v reason = %q, want nil and empty reason", got, reason) + } + }) +} + +func TestChannelIdentityEnvironmentRejectsPartialOrInconsistentPayload(t *testing.T) { + tests := []struct { + name string + task Task + reason string + }{ + { + name: "not a channel chat", + task: Task{ + ChatChannelType: execenv.ChannelTypeFeishu, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeFeishu, + InstallationID: "installation", + ChannelUserID: "ou_sender", + }, + }, + reason: "task_not_channel_chat", + }, + { + name: "incomplete", + task: Task{ + ChatSessionID: "chat", + ChatChannelType: execenv.ChannelTypeFeishu, + ChannelIdentity: &ChannelIdentityData{ChannelType: execenv.ChannelTypeFeishu}, + }, + reason: "identity_incomplete", + }, + { + name: "channel mismatch", + task: Task{ + ChatSessionID: "chat", + ChatChannelType: execenv.ChannelTypeSlack, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeFeishu, + InstallationID: "installation", + ChannelUserID: "ou_sender", + }, + }, + reason: "channel_type_mismatch", + }, + { + name: "control character", + task: Task{ + ChatSessionID: "chat", + ChatChannelType: execenv.ChannelTypeFeishu, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeFeishu, + InstallationID: "installation", + ChannelUserID: "ou_sender\ninjected", + }, + }, + reason: "identity_invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, reason := channelIdentityEnvironment(tc.task) + if got != nil || reason != tc.reason { + t.Fatalf("environment = %#v reason = %q, want nil reason %q", got, reason, tc.reason) + } + }) + } +} + +func TestChannelIdentityEnvironmentCannotBeOverriddenByCustomEnv(t *testing.T) { + task := Task{ + ChatSessionID: "chat", + ChatChannelType: execenv.ChannelTypeFeishu, + ChannelIdentity: &ChannelIdentityData{ + ChannelType: execenv.ChannelTypeFeishu, + InstallationID: "installation", + ChannelUserID: "ou_attested", + }, + } + agentEnv, reason := channelIdentityEnvironment(task) + if reason != "" { + t.Fatalf("channelIdentityEnvironment reason = %q", reason) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + layerCustomEnvAndHermesHome(agentEnv, map[string]string{ + channelTypeEnv: "slack", + channelInstallationIDEnv: "forged-installation", + channelUserIDEnv: "U_FORGED", + larkOpenIDEnv: "ou_forged", + }, "", logger) + + if agentEnv[channelTypeEnv] != execenv.ChannelTypeFeishu || + agentEnv[channelInstallationIDEnv] != "installation" || + agentEnv[channelUserIDEnv] != "ou_attested" || + agentEnv[larkOpenIDEnv] != "ou_attested" { + t.Fatalf("custom env overrode attested identity: %#v", agentEnv) + } +} + +func TestChannelIdentityEnvironmentIsVisibleToCodexShellPolicy(t *testing.T) { + explicit := map[string]string{ + channelTypeEnv: execenv.ChannelTypeFeishu, + channelInstallationIDEnv: "installation", + channelUserIDEnv: "ou_sender", + larkOpenIDEnv: "ou_sender", + } + allowed := execenv.CodexShellEnvAllowlist(nil, explicit, nil) + seen := make(map[string]bool, len(allowed)) + for _, key := range allowed { + seen[key] = true + } + for key := range explicit { + if !seen[key] { + t.Errorf("%s missing from Codex shell environment allowlist: %v", key, allowed) + } + } +} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 4eaa8fc331..49b63d5090 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -60,6 +60,10 @@ const ( taskSlotCapacityBackoff = 5 * time.Second repoCheckoutModeEnv = "MULTICA_REPO_CHECKOUT_MODE" repoCheckoutModeIsolated = "isolated" + channelTypeEnv = "MULTICA_CHANNEL_TYPE" + channelInstallationIDEnv = "MULTICA_CHANNEL_INSTALLATION_ID" + channelUserIDEnv = "MULTICA_CHANNEL_USER_ID" + larkOpenIDEnv = "MULTICA_LARK_OPEN_ID" // defaultTaskPrepareTimeout is a hard liveness bound for everything after // claim and before StartTask succeeds: runtime resolution, skill bundles, // execution-environment setup, and the StartTask request itself. It is @@ -91,6 +95,45 @@ func taskScopedAuthToken(task Task) (string, error) { return token, nil } +// channelIdentityEnvironment converts server-attested channel identity into +// per-task child-process metadata. It intentionally returns a fresh map only +// when the payload is complete and consistent, so callers never expose a +// partial identity tuple. The values remain attribution metadata: credential +// routing continues to be derived from MULTICA_TOKEN by the task broker. +func channelIdentityEnvironment(task Task) (map[string]string, string) { + if task.ChannelIdentity == nil { + return nil, "" + } + if strings.TrimSpace(task.ChatSessionID) == "" { + return nil, "task_not_channel_chat" + } + + channelType := strings.TrimSpace(task.ChannelIdentity.ChannelType) + installationID := strings.TrimSpace(task.ChannelIdentity.InstallationID) + channelUserID := strings.TrimSpace(task.ChannelIdentity.ChannelUserID) + if channelType == "" || installationID == "" || channelUserID == "" { + return nil, "identity_incomplete" + } + if channelType != strings.TrimSpace(task.ChatChannelType) { + return nil, "channel_type_mismatch" + } + for _, value := range []string{channelType, installationID, channelUserID} { + if len(value) > 512 || strings.ContainsAny(value, "\x00\r\n") { + return nil, "identity_invalid" + } + } + + env := map[string]string{ + channelTypeEnv: channelType, + channelInstallationIDEnv: installationID, + channelUserIDEnv: channelUserID, + } + if channelType == execenv.ChannelTypeFeishu { + env[larkOpenIDEnv] = channelUserID + } + return env, "" +} + // taskRunner executes a single agent task and returns the result. // Extracted as an interface so tests can inject a fake without spawning real // agent processes, while keeping test scaffolding out of the production struct. @@ -4464,6 +4507,16 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i "TMP": taskTempDir, "TEMP": taskTempDir, } + if channelEnv, reason := channelIdentityEnvironment(task); reason != "" { + taskLog.Warn("channel identity environment omitted", + "channel_type", task.ChatChannelType, + "reason", reason, + ) + } else { + for key, value := range channelEnv { + agentEnv[key] = value + } + } if checkoutMode := repoCheckoutModeFor(provider, runtime.GOOS); checkoutMode != "" { agentEnv[repoCheckoutModeEnv] = checkoutMode } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 54a1fcb234..a0c1bdb13c 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -54,6 +54,15 @@ type ProjectResourceData struct { // sharing the canonical JSON shape with the runtime app metadata package. type ConnectedAppData = runtimeapps.ConnectedApp +// ChannelIdentityData mirrors the optional server-attested channel identity in +// a daemon claim. ChannelUserID is installation-scoped provider metadata (for +// Feishu, open_id), not a credential or authorization input. +type ChannelIdentityData struct { + ChannelType string `json:"channel_type"` + InstallationID string `json:"installation_id"` + ChannelUserID string `json:"channel_user_id"` +} + // Task represents a claimed task from the server. // Agent data (name, skills) is populated by the claim endpoint. type Task struct { @@ -89,6 +98,7 @@ type Task struct { NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; empty on cold start ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Drives the channel-awareness block in the prompt + ChannelIdentity *ChannelIdentityData `json:"channel_identity,omitempty"` // server-attested direct-human identity for this channel installation ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; selects which read command the prompt tells the agent to start with ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download ` diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index 06e9c465a9..62f1146037 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -270,6 +270,16 @@ type ProjectResourceData struct { // while sharing the canonical JSON shape with the runtime app metadata package. type ConnectedAppData = runtimeapps.ConnectedApp +// ChannelIdentityData is server-attested identity metadata for the direct +// human who originated a channel-backed chat task. ChannelUserID is the +// provider-native, installation-scoped id (for Feishu, the sender's open_id). +// It is attribution metadata, never a credential or credential selector. +type ChannelIdentityData struct { + ChannelType string `json:"channel_type"` + InstallationID string `json:"installation_id"` + ChannelUserID string `json:"channel_user_id"` +} + type AgentTaskResponse struct { ID string `json:"id"` AgentID string `json:"agent_id"` @@ -330,6 +340,7 @@ type AgentTaskResponse struct { NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; omitempty so old daemons ignore it ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Makes the agent channel-aware (read history from the channel, not Multica) + ChannelIdentity *ChannelIdentityData `json:"channel_identity,omitempty"` // server-attested direct-human identity for the current channel installation ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; tells the agent to start with `multica chat thread` vs `multica chat history` ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download ` per entry diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 528b2e46ef..7e5adb71e7 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -2080,6 +2080,7 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu continue } resp.ChatChannelType = string(channelType) + resp.ChannelIdentity = h.resolveClaimChannelIdentity(r.Context(), *task, cs.WorkspaceID, binding) if channelType == slack.TypeSlack { // The latest trigger was a thread reply iff its reply-target // thread (last_thread_id) differs from its own message id (a @@ -2472,6 +2473,99 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu return resp, deliveredCommentIDs, agentSkillCount, builtinSkillCount, nil } +// resolveClaimChannelIdentity reverses the installation-scoped inbound binding +// for the direct human attributed to a channel chat task. The result is safe to +// pass to the daemon as identity metadata, but it is never an authorization +// input: authenticated channel operations continue to use the task-scoped +// credential broker. Every relationship is revalidated at claim time because +// the generalized channel tables deliberately carry no foreign keys. +func (h *Handler) resolveClaimChannelIdentity(ctx context.Context, task db.AgentTaskQueue, workspaceID pgtype.UUID, chatBinding db.ChannelChatSessionBinding) *ChannelIdentityData { + taskID := uuidToString(task.ID) + channelType := strings.TrimSpace(chatBinding.ChannelType) + + if !task.InitiatorUserID.Valid || + !task.OriginatorUserID.Valid || + !task.OriginatorSource.Valid || + task.OriginatorSource.String != "direct_human" || + task.InitiatorUserID.Bytes != task.OriginatorUserID.Bytes { + slog.Debug("daemon claim: channel identity omitted for non-direct initiator", + "task_id", taskID, + "channel_type", channelType, + ) + return nil + } + + installation, err := h.Queries.GetChannelInstallation(ctx, db.GetChannelInstallationParams{ + ID: chatBinding.InstallationID, + ChannelType: channelType, + }) + if err != nil { + slog.Warn("daemon claim: channel identity omitted", + "task_id", taskID, + "channel_type", channelType, + "reason", "installation_unavailable", + ) + return nil + } + if installation.Status != "active" || + !workspaceID.Valid || + installation.WorkspaceID.Bytes != workspaceID.Bytes || + installation.AgentID.Bytes != task.AgentID.Bytes { + slog.Warn("daemon claim: channel identity omitted", + "task_id", taskID, + "channel_type", channelType, + "reason", "installation_mismatch", + ) + return nil + } + + if _, err := h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{ + UserID: task.InitiatorUserID, + WorkspaceID: workspaceID, + }); err != nil { + slog.Warn("daemon claim: channel identity omitted", + "task_id", taskID, + "channel_type", channelType, + "reason", "initiator_not_member", + ) + return nil + } + + bindings, err := h.Queries.ListChannelUserBindingsForClaim(ctx, db.ListChannelUserBindingsForClaimParams{ + WorkspaceID: workspaceID, + InstallationID: chatBinding.InstallationID, + ChannelType: channelType, + MulticaUserID: task.InitiatorUserID, + }) + if err != nil { + slog.Warn("daemon claim: channel identity omitted", + "task_id", taskID, + "channel_type", channelType, + "reason", "binding_lookup_failed", + ) + return nil + } + if len(bindings) != 1 || strings.TrimSpace(bindings[0].ChannelUserID) == "" { + reason := "identity_unbound" + if len(bindings) > 1 { + reason = "identity_ambiguous" + } + slog.Warn("daemon claim: channel identity omitted", + "task_id", taskID, + "channel_type", channelType, + "reason", reason, + "binding_count", len(bindings), + ) + return nil + } + + return &ChannelIdentityData{ + ChannelType: channelType, + InstallationID: uuidToString(chatBinding.InstallationID), + ChannelUserID: bindings[0].ChannelUserID, + } +} + // ClaimTaskByRuntime atomically claims the next queued task for a runtime. // The response includes the agent's name and skills, fetched fresh from the DB. func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/handler/daemon_claim_channel_identity_test.go b/server/internal/handler/daemon_claim_channel_identity_test.go new file mode 100644 index 0000000000..ce4655cf22 --- /dev/null +++ b/server/internal/handler/daemon_claim_channel_identity_test.go @@ -0,0 +1,345 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type claimChannelIdentityFixture struct { + AgentID string + SessionID string + RuntimeID string + DaemonID string + TaskID string + InstallationID string + UserID string + ChannelType string + ChannelUserID string +} + +func seedClaimChannelIdentityFixture(t *testing.T, ctx context.Context, channelType, userID, channelUserID string) claimChannelIdentityFixture { + t.Helper() + agentID, sessionID, runtimeID, daemonID := setupDirectChatSession(t, ctx, channelType+" identity chat") + + var installationID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO channel_installation ( + workspace_id, agent_id, channel_type, installer_user_id, status + ) + VALUES ($1, $2, $3, $4, 'active') + RETURNING id + `, testWorkspaceID, agentID, channelType, testUserID).Scan(&installationID); err != nil { + t.Fatalf("seed channel installation: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO channel_chat_session_binding ( + chat_session_id, installation_id, channel_type, channel_chat_id, chat_type + ) + VALUES ($1, $2, $3, $4, 'group') + `, sessionID, installationID, channelType, "C-IDENTITY-"+sessionID); err != nil { + t.Fatalf("seed channel chat binding: %v", err) + } + if channelUserID != "" { + if _, err := testPool.Exec(ctx, ` + INSERT INTO channel_user_binding ( + workspace_id, multica_user_id, installation_id, channel_type, channel_user_id + ) + VALUES ($1, $2, $3, $4, $5) + `, testWorkspaceID, userID, installationID, channelType, channelUserID); err != nil { + t.Fatalf("seed channel user binding: %v", err) + } + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO chat_message (chat_session_id, role, content) + VALUES ($1, 'user', 'identity test message') + `, sessionID); err != nil { + t.Fatalf("seed chat message: %v", err) + } + + session, err := testHandler.Queries.GetChatSession(ctx, parseUUID(sessionID)) + if err != nil { + t.Fatalf("load chat session: %v", err) + } + task, err := testHandler.TaskService.EnqueueChatTask(ctx, session, parseUUID(userID), false) + if err != nil { + t.Fatalf("enqueue channel chat task: %v", err) + } + taskID := uuidToString(task.ID) + + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) + testPool.Exec(ctx, `DELETE FROM channel_user_binding WHERE installation_id = $1`, installationID) + testPool.Exec(ctx, `DELETE FROM channel_chat_session_binding WHERE installation_id = $1`, installationID) + testPool.Exec(ctx, `DELETE FROM channel_installation WHERE id = $1`, installationID) + }) + + return claimChannelIdentityFixture{ + AgentID: agentID, + SessionID: sessionID, + RuntimeID: runtimeID, + DaemonID: daemonID, + TaskID: taskID, + InstallationID: installationID, + UserID: userID, + ChannelType: channelType, + ChannelUserID: channelUserID, + } +} + +func claimChannelIdentity(t *testing.T, fixture claimChannelIdentityFixture) (string, *ChannelIdentityData) { + t.Helper() + w := httptest.NewRecorder() + req := newDaemonTokenRequest( + http.MethodPost, + "/api/daemon/runtimes/"+fixture.RuntimeID+"/tasks/claim", + nil, + testWorkspaceID, + fixture.DaemonID, + ) + req = withURLParam(req, "runtimeId", fixture.RuntimeID) + testHandler.ClaimTaskByRuntime(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Task *struct { + ID string `json:"id"` + ChatChannelType string `json:"chat_channel_type"` + ChannelIdentity *ChannelIdentityData `json:"channel_identity"` + } `json:"task"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("decode claim response: %v", err) + } + if response.Task == nil { + t.Fatalf("expected a claimed task, got %s", w.Body.String()) + } + if response.Task.ID != fixture.TaskID { + t.Fatalf("claimed task = %q, want %q", response.Task.ID, fixture.TaskID) + } + return response.Task.ChatChannelType, response.Task.ChannelIdentity +} + +func TestClaimTaskByRuntime_ChannelIdentity(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + + for _, tc := range []struct { + name string + channelType string + channelUserID string + }{ + {name: "Feishu open id", channelType: "feishu", channelUserID: "ou_claim_user"}, + {name: "Slack user id", channelType: "slack", channelUserID: "U_CLAIM_USER"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, tc.channelType, testUserID, tc.channelUserID) + channelType, identity := claimChannelIdentity(t, fixture) + + if channelType != tc.channelType { + t.Fatalf("chat_channel_type = %q, want %q", channelType, tc.channelType) + } + if identity == nil { + t.Fatal("channel_identity is missing") + } + if identity.ChannelType != tc.channelType || + identity.InstallationID != fixture.InstallationID || + identity.ChannelUserID != tc.channelUserID { + t.Fatalf("channel_identity = %#v, want type=%q installation=%q user=%q", + identity, tc.channelType, fixture.InstallationID, tc.channelUserID) + } + }) + } +} + +func TestClaimTaskByRuntime_ChannelIdentityUsesCurrentSenderNotSessionCreator(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + var senderID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO "user" (name, email) + VALUES ('Second Channel Sender', 'second-channel-sender@multica.test') + RETURNING id + `).Scan(&senderID); err != nil { + t.Fatalf("seed second sender: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) + VALUES ($1, $2, 'member') + `, testWorkspaceID, senderID); err != nil { + t.Fatalf("seed second sender membership: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, senderID) + testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, senderID) + }) + + // setupDirectChatSession creates the session as testUserID (the installer), + // while the queued task is attributed to senderID. The claim must resolve + // the sender's binding, not the session creator's. + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", senderID, "ou_second_sender") + _, identity := claimChannelIdentity(t, fixture) + if identity == nil || identity.ChannelUserID != "ou_second_sender" { + t.Fatalf("channel_identity = %#v, want second sender binding", identity) + } +} + +func TestClaimTaskByRuntime_ChannelIdentityOmittedWhenUnboundOrAmbiguous(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + + t.Run("unbound", func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "") + channelType, identity := claimChannelIdentity(t, fixture) + if channelType != "feishu" { + t.Fatalf("chat_channel_type = %q, want feishu", channelType) + } + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) + + t.Run("ambiguous", func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "ou_first") + if _, err := testPool.Exec(ctx, ` + INSERT INTO channel_user_binding ( + workspace_id, multica_user_id, installation_id, channel_type, channel_user_id + ) + VALUES ($1, $2, $3, 'feishu', 'ou_second') + `, testWorkspaceID, testUserID, fixture.InstallationID); err != nil { + t.Fatalf("seed ambiguous binding: %v", err) + } + _, identity := claimChannelIdentity(t, fixture) + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) +} + +func TestClaimTaskByRuntime_ChannelIdentityRevalidatesInstallationAndMember(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + + t.Run("inactive installation", func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "ou_inactive") + if _, err := testPool.Exec(ctx, ` + UPDATE channel_installation SET status = 'revoked' WHERE id = $1 + `, fixture.InstallationID); err != nil { + t.Fatalf("revoke installation: %v", err) + } + _, identity := claimChannelIdentity(t, fixture) + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) + + t.Run("agent mismatch", func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "ou_wrong_agent") + if _, err := testPool.Exec(ctx, ` + UPDATE channel_installation + SET agent_id = '00000000-0000-0000-0000-000000000098'::uuid + WHERE id = $1 + `, fixture.InstallationID); err != nil { + t.Fatalf("change installation agent: %v", err) + } + _, identity := claimChannelIdentity(t, fixture) + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) + + t.Run("workspace mismatch", func(t *testing.T) { + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "ou_wrong_workspace") + if _, err := testPool.Exec(ctx, ` + UPDATE channel_installation + SET workspace_id = '00000000-0000-0000-0000-000000000097'::uuid + WHERE id = $1 + `, fixture.InstallationID); err != nil { + t.Fatalf("change installation workspace: %v", err) + } + _, identity := claimChannelIdentity(t, fixture) + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) + + t.Run("removed member", func(t *testing.T) { + ctx := context.Background() + var userID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO "user" (name, email) + VALUES ('Removed Channel Member', 'removed-channel-member@multica.test') + RETURNING id + `).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + if _, err := testPool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) + VALUES ($1, $2, 'member') + `, testWorkspaceID, userID); err != nil { + t.Fatalf("seed member: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM member WHERE workspace_id = $1 AND user_id = $2`, testWorkspaceID, userID) + testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID) + }) + + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", userID, "ou_removed") + if _, err := testPool.Exec(ctx, ` + DELETE FROM member WHERE workspace_id = $1 AND user_id = $2 + `, testWorkspaceID, userID); err != nil { + t.Fatalf("remove member: %v", err) + } + _, identity := claimChannelIdentity(t, fixture) + if identity != nil { + t.Fatalf("channel_identity = %#v, want omitted", identity) + } + }) +} + +func TestResolveClaimChannelIdentityRequiresDirectMatchingOriginator(t *testing.T) { + if testHandler == nil || testPool == nil { + t.Skip("database not available") + } + ctx := context.Background() + fixture := seedClaimChannelIdentityFixture(t, ctx, "feishu", testUserID, "ou_direct") + task, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(fixture.TaskID)) + if err != nil { + t.Fatalf("load task: %v", err) + } + binding, err := testHandler.Queries.GetChannelChatSessionBindingBySession(ctx, db.GetChannelChatSessionBindingBySessionParams{ + ChatSessionID: parseUUID(fixture.SessionID), + ChannelType: "feishu", + }) + if err != nil { + t.Fatalf("load channel chat binding: %v", err) + } + + task.OriginatorSource.String = "delegation" + if got := testHandler.resolveClaimChannelIdentity(ctx, task, parseUUID(testWorkspaceID), binding); got != nil { + t.Fatalf("delegated task resolved channel identity: %#v", got) + } + + task.OriginatorSource.String = "direct_human" + task.OriginatorUserID = parseUUID("00000000-0000-0000-0000-000000000099") + if got := testHandler.resolveClaimChannelIdentity(ctx, task, parseUUID(testWorkspaceID), binding); got != nil { + t.Fatalf("mismatched originator resolved channel identity: %#v", got) + } +} diff --git a/server/pkg/db/generated/channel.sql.go b/server/pkg/db/generated/channel.sql.go index 606b5a2c5d..ae57017ccd 100644 --- a/server/pkg/db/generated/channel.sql.go +++ b/server/pkg/db/generated/channel.sql.go @@ -1014,6 +1014,62 @@ func (q *Queries) ListChannelInstallationsByWorkspace(ctx context.Context, arg L return items, nil } +const listChannelUserBindingsForClaim = `-- name: ListChannelUserBindingsForClaim :many +SELECT id, workspace_id, multica_user_id, installation_id, channel_type, channel_user_id, config, bound_at FROM channel_user_binding +WHERE workspace_id = $1 + AND installation_id = $2 + AND channel_type = $3 + AND multica_user_id = $4 +ORDER BY bound_at DESC, id DESC +LIMIT 2 +` + +type ListChannelUserBindingsForClaimParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + InstallationID pgtype.UUID `json:"installation_id"` + ChannelType string `json:"channel_type"` + MulticaUserID pgtype.UUID `json:"multica_user_id"` +} + +// Reverse lookup used only when building a daemon claim for a direct-human +// channel task. A provider-native user id is installation-scoped, so every +// dimension is pinned here. LIMIT 2 lets the caller distinguish the expected +// single binding from an ambiguous multi-binding without loading an unbounded +// result set. The claim path re-checks live workspace membership separately. +func (q *Queries) ListChannelUserBindingsForClaim(ctx context.Context, arg ListChannelUserBindingsForClaimParams) ([]ChannelUserBinding, error) { + rows, err := q.db.Query(ctx, listChannelUserBindingsForClaim, + arg.WorkspaceID, + arg.InstallationID, + arg.ChannelType, + arg.MulticaUserID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChannelUserBinding{} + for rows.Next() { + var i ChannelUserBinding + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.MulticaUserID, + &i.InstallationID, + &i.ChannelType, + &i.ChannelUserID, + &i.Config, + &i.BoundAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markChannelInboundDedupProcessed = `-- name: MarkChannelInboundDedupProcessed :execrows UPDATE channel_inbound_message_dedup SET processed_at = now() diff --git a/server/pkg/db/queries/channel.sql b/server/pkg/db/queries/channel.sql index 3464c93a77..63da626b83 100644 --- a/server/pkg/db/queries/channel.sql +++ b/server/pkg/db/queries/channel.sql @@ -367,6 +367,20 @@ RETURNING *; SELECT * FROM channel_user_binding WHERE installation_id = $1 AND channel_user_id = $2; +-- name: ListChannelUserBindingsForClaim :many +-- Reverse lookup used only when building a daemon claim for a direct-human +-- channel task. A provider-native user id is installation-scoped, so every +-- dimension is pinned here. LIMIT 2 lets the caller distinguish the expected +-- single binding from an ambiguous multi-binding without loading an unbounded +-- result set. The claim path re-checks live workspace membership separately. +SELECT * FROM channel_user_binding +WHERE workspace_id = sqlc.arg('workspace_id') + AND installation_id = sqlc.arg('installation_id') + AND channel_type = sqlc.arg('channel_type') + AND multica_user_id = sqlc.arg('multica_user_id') +ORDER BY bound_at DESC, id DESC +LIMIT 2; + -- name: FindReusableChannelUserBinding :one -- Cross-installation account-link reuse (MUL-3911). When a platform user -- messages an installation they have NOT linked, but the SAME user id is already