Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions server/internal/daemon/channel_identity_env_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
53 changes: 53 additions & 0 deletions server/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions server/internal/daemon/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <id>`
Expand Down
11 changes: 11 additions & 0 deletions server/internal/handler/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 <id>` per entry
Expand Down
Loading
Loading