Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan
31a22ff30f fix(agent): ensure daemon env vars override parent environment
buildEnv() was appending extra env vars to os.Environ() without
deduplicating. When the parent process already had a key (e.g.
MULTICA_AGENT_ID from a prior run or shell profile), os.Getenv in
the child would return the stale first occurrence, causing agent
CLI actions to silently fall back to user identity.

Now buildEnv removes parent entries that will be overridden before
appending the daemon-provided values.

Also:
- Add X-Agent-ID to CORS allowed headers
- Add handler tests for agent comment/issue attribution via X-Agent-ID
- Add unit test verifying buildEnv override behavior
2026-03-30 03:38:34 +08:00
4 changed files with 183 additions and 2 deletions

View File

@@ -58,7 +58,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID"},
AllowCredentials: true,
MaxAge: 300,
}))

View File

@@ -299,6 +299,148 @@ func TestCommentCRUD(t *testing.T) {
testHandler.DeleteIssue(w, req)
}
func TestCommentAgentAttribution(t *testing.T) {
// Look up the test agent ID.
w := httptest.NewRecorder()
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
testHandler.ListAgents(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
}
var agents []AgentResponse
json.NewDecoder(w.Body).Decode(&agents)
if len(agents) == 0 {
t.Fatal("need at least 1 agent in test fixture")
}
agentID := agents[0].ID
// Create a test issue.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent comment attribution test",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
cw := httptest.NewRecorder()
cr := newRequest("DELETE", "/api/issues/"+issueID, nil)
cr = withURLParam(cr, "id", issueID)
testHandler.DeleteIssue(cw, cr)
})
// Create a comment WITHOUT X-Agent-ID — should be "member".
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "member comment",
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment (member): expected 201, got %d: %s", w.Code, w.Body.String())
}
var memberComment CommentResponse
json.NewDecoder(w.Body).Decode(&memberComment)
if memberComment.AuthorType != "member" {
t.Fatalf("expected author_type 'member', got %q", memberComment.AuthorType)
}
if memberComment.AuthorID != testUserID {
t.Fatalf("expected author_id %q, got %q", testUserID, memberComment.AuthorID)
}
// Create a comment WITH X-Agent-ID — should be "agent".
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "agent comment",
})
req.Header.Set("X-Agent-ID", agentID)
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment (agent): expected 201, got %d: %s", w.Code, w.Body.String())
}
var agentComment CommentResponse
json.NewDecoder(w.Body).Decode(&agentComment)
if agentComment.AuthorType != "agent" {
t.Fatalf("expected author_type 'agent', got %q", agentComment.AuthorType)
}
if agentComment.AuthorID != agentID {
t.Fatalf("expected author_id %q, got %q", agentID, agentComment.AuthorID)
}
// Verify via list: both comments present with correct attribution.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListComments(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
}
var comments []CommentResponse
json.NewDecoder(w.Body).Decode(&comments)
if len(comments) != 2 {
t.Fatalf("expected 2 comments, got %d", len(comments))
}
// Comments are ordered by created_at ASC.
if comments[0].AuthorType != "member" {
t.Fatalf("first comment: expected author_type 'member', got %q", comments[0].AuthorType)
}
if comments[1].AuthorType != "agent" {
t.Fatalf("second comment: expected author_type 'agent', got %q", comments[1].AuthorType)
}
}
func TestUpdateIssueAgentAttribution(t *testing.T) {
// Look up the test agent ID.
w := httptest.NewRecorder()
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
testHandler.ListAgents(w, req)
var agents []AgentResponse
json.NewDecoder(w.Body).Decode(&agents)
if len(agents) == 0 {
t.Fatal("need at least 1 agent in test fixture")
}
agentID := agents[0].ID
// Create a test issue.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent update attribution test",
"status": "todo",
})
testHandler.CreateIssue(w, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
cw := httptest.NewRecorder()
cr := newRequest("DELETE", "/api/issues/"+issueID, nil)
cr = withURLParam(cr, "id", issueID)
testHandler.DeleteIssue(cw, cr)
})
// Update with X-Agent-ID — the response should succeed (we can't easily
// inspect the event bus, but we verify the handler doesn't reject the header).
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
"status": "in_progress",
})
req.Header.Set("X-Agent-ID", agentID)
req = withURLParam(req, "id", issueID)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue with X-Agent-ID: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "in_progress" {
t.Fatalf("expected status 'in_progress', got %q", updated.Status)
}
}
func TestAgentCRUD(t *testing.T) {
// List agents
w := httptest.NewRecorder()

View File

@@ -315,7 +315,22 @@ func trySend(ch chan<- Message, msg Message) {
}
func buildEnv(extra map[string]string) []string {
env := os.Environ()
// Build a set of keys we want to override so we can skip them
// when copying the parent environment. This ensures extra vars
// always win — os.Getenv in child processes returns the first
// match, so appending duplicates would silently use the old value.
override := make(map[string]bool, len(extra))
for k := range extra {
override[k] = true
}
env := make([]string, 0, len(os.Environ())+len(extra))
for _, entry := range os.Environ() {
if k, _, ok := strings.Cut(entry, "="); ok && override[k] {
continue // skip — will be replaced by extra
}
env = append(env, entry)
}
for k, v := range extra {
env = append(env, k+"="+v)
}

View File

@@ -218,6 +218,30 @@ func TestBuildEnvNilExtras(t *testing.T) {
}
}
func TestBuildEnvOverridesDuplicates(t *testing.T) {
// Cannot use t.Parallel with t.Setenv.
// Inject a known key into the current process environment so we can
// verify that buildEnv replaces it instead of appending a duplicate.
const key = "MULTICA_TEST_BUILD_ENV_OVERRIDE"
t.Setenv(key, "old_value")
env := buildEnv(map[string]string{key: "new_value"})
var matches []string
for _, e := range env {
if strings.HasPrefix(e, key+"=") {
matches = append(matches, e)
}
}
if len(matches) != 1 {
t.Fatalf("expected exactly 1 entry for %s, got %d: %v", key, len(matches), matches)
}
if matches[0] != key+"=new_value" {
t.Fatalf("expected %s=new_value, got %q", key, matches[0])
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)