Compare commits

..

6 Commits

Author SHA1 Message Date
Jiayuan Zhang
3df95c84b8 fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors (#1980)
* fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors

TestRegisterTaskReposAllowsProjectOnlyURL and
TestRegisterTaskReposSurvivesWorkspaceRefresh fail on GitHub Actions CI
because git clone --bare from local temp directories triggers git's
safe.directory ownership check when the runner UID differs from the
directory owner.

Set safe.directory=* via GIT_CONFIG env vars in gitEnv() so all daemon
git subprocesses trust any directory. The daemon manages its own bare
caches and worktrees, so the ownership check provides no security value.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): preserve existing GIT_CONFIG_* entries in gitEnv

Instead of resetting GIT_CONFIG_COUNT to 1, read the existing count
from the environment and append safe.directory at the next available
index. This preserves any env-scoped git config (auth, URL rewrites,
extra headers) injected into the daemon process.

Adds TestGitEnvPreservesExistingConfig to verify the append behavior.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 16:18:58 +02:00
Jiayuan Zhang
050a2f0a5b fix(views): preserve kanban display settings when dragging issues (#1971)
Dragging an issue between kanban columns was forcefully switching the
sort mode to "position" (manual), resetting any user-chosen display
settings like sorting by title. Remove the auto-switch so the sort
preference is preserved across drag operations.

Fixes multica-ai/multica#1960

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 15:55:01 +02:00
Jiayuan Zhang
374f62be13 feat(inbox): remove redundant mark-as-done hover button, add archive button for done tasks (#1970)
Remove the "mark as done" hover button from inbox list items since it
duplicates the one in the issue detail header. For done tasks, show an
archive button in the issue detail header instead.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 09:19:15 +02:00
Jiayuan Zhang
d9e5cf87dd fix(views): responsive Autopilot list for mobile viewports (#1961)
Switch Autopilot list rows to a stacked layout below the sm breakpoint,
hide desktop column headers on mobile, and match loading skeletons to
the mobile row shape. Desktop table layout is preserved at sm and above.

Closes MUL-1653

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:19:19 +02:00
Jiayuan Zhang
13fe614903 fix(daemon): optimize quick-create prompt for high-fidelity descriptions (#1969)
The previous description rule ("stay faithful + keep it concise") caused
agents to over-compress user input into vague single-sentence summaries,
losing context that the executing agent needs.

Key changes:
- Replace "keep it concise" with structured two-section format:
  User request (faithful restate) + Context (verifiable external facts)
- Add hard rules against information compression and semantic downgrading
- Remove "one-line description" phrasing (UI supports richer input)
- Strip redundant behavioral rules from issue_context.md (already
  covered by AGENTS.md guardrails and per-turn prompt)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 08:14:55 +02:00
wucm667
2305f7d180 fix(skill): sanitize null bytes in all skill update/upsert paths to prevent PostgreSQL UTF8 error (#1959) 2026-04-30 22:34:24 +02:00
11 changed files with 153 additions and 99 deletions

View File

@@ -130,38 +130,40 @@ function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
const StatusIcon = statusCfg.icon;
return (
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
<div className="group/row flex flex-col gap-2 border-b px-4 py-3 text-sm transition-colors hover:bg-accent/40 sm:h-11 sm:flex-row sm:items-center sm:gap-2 sm:border-b-0 sm:px-5 sm:py-0">
<AppLink
href={wsPaths.autopilotDetail(autopilot.id)}
className="flex min-w-0 flex-1 items-center gap-2"
className="flex min-w-0 items-center gap-2 sm:flex-1"
>
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
</AppLink>
{/* Agent */}
<span className="flex w-32 items-center gap-1.5 shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
<span className="truncate text-xs text-muted-foreground">
{getActorName("agent", autopilot.assignee_id)}
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-xs sm:contents sm:pl-0">
{/* Agent */}
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground sm:w-32 sm:shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} enableHoverCard showStatusDot />
<span className="truncate">
{getActorName("agent", autopilot.assignee_id)}
</span>
</span>
</span>
{/* Mode */}
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</span>
{/* Mode */}
<span className="text-muted-foreground sm:w-24 sm:shrink-0 sm:text-center">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</span>
{/* Status */}
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
<StatusIcon className="h-3 w-3" />
{statusCfg.label}
</span>
{/* Status */}
<span className={cn("flex items-center gap-1 sm:w-20 sm:shrink-0 sm:justify-center", statusCfg.color)}>
<StatusIcon className="h-3 w-3" />
{statusCfg.label}
</span>
{/* Last run */}
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
</span>
{/* Last run */}
<span className="text-muted-foreground tabular-nums sm:w-20 sm:shrink-0 sm:text-right">
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
</span>
</div>
</div>
);
}
@@ -198,7 +200,7 @@ export function AutopilotsPage() {
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<>
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5">
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 sm:flex">
<span className="shrink-0 w-4" />
<Skeleton className="h-3 w-12 flex-1 max-w-[48px]" />
<Skeleton className="h-3 w-12 shrink-0" />
@@ -206,9 +208,9 @@ export function AutopilotsPage() {
<Skeleton className="h-3 w-10 shrink-0" />
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="p-5 pt-1 space-y-1">
<div className="space-y-2 p-4 sm:space-y-1 sm:p-5 sm:pt-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
<Skeleton key={i} className="h-[72px] w-full sm:h-11" />
))}
</div>
</>
@@ -246,7 +248,7 @@ export function AutopilotsPage() {
) : (
<>
{/* Column headers */}
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
<div className="sticky top-0 z-[1] hidden h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground sm:flex">
<span className="shrink-0 w-4" />
<span className="min-w-0 flex-1">Name</span>
<span className="w-32 shrink-0">Agent</span>

View File

@@ -2,7 +2,7 @@
import { StatusIcon } from "../../issues/components";
import { ActorAvatar } from "../../common/actor-avatar";
import { Archive, CircleCheck } from "lucide-react";
import { Archive } from "lucide-react";
import type { InboxItem } from "@multica/core/types";
import { InboxDetailLabel } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
@@ -25,13 +25,11 @@ export function InboxListItem({
isSelected,
onClick,
onArchive,
onDone,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
onDone?: () => void;
}) {
const displayTitle = getInboxDisplayTitle(item);
@@ -61,26 +59,6 @@ export function InboxListItem({
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{onDone && (
<span
role="button"
tabIndex={-1}
title="Mark as done"
onClick={(e) => {
e.stopPropagation();
onDone();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onDone();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-info group-hover:inline-flex"
>
<CircleCheck className="h-3.5 w-3.5" />
</span>
)}
<span
role="button"
tabIndex={-1}

View File

@@ -20,7 +20,7 @@ import {
useArchiveAllReadInbox,
useArchiveCompletedInbox,
} from "@multica/core/inbox/mutations";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { IssueDetail } from "../../issues/components";
import { useNavigation } from "../../navigation";
import { toast } from "sonner";
@@ -118,8 +118,6 @@ export function InboxPage() {
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
const updateIssueMutation = useUpdateIssue();
// Auto-mark-read whenever a selected item is unread — covers both click-
// to-select and URL-param-select (e.g. OS notification click on desktop).
// The mutation flips `read: true` optimistically, so this effect settles
@@ -147,18 +145,6 @@ export function InboxPage() {
});
};
const handleDone = (item: InboxItem) => {
if (!item.issue_id) return;
setSelectedKey("");
updateIssueMutation.mutate(
{ id: item.issue_id, status: "done" },
{ onError: () => toast.error("Failed to mark as done") },
);
archiveMutation.mutate(item.id, {
onError: () => toast.error("Failed to archive"),
});
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
@@ -249,11 +235,6 @@ export function InboxPage() {
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
onDone={
item.issue_id && item.issue_status !== "done" && item.issue_status !== "cancelled"
? () => handleDone(item)
: undefined
}
/>
))}
</div>

View File

@@ -5,6 +5,7 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { AppLink } from "../../navigation";
import { useNavigation } from "../../navigation";
import {
Archive,
Calendar,
ChevronDown,
ChevronLeft,
@@ -549,6 +550,23 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<TooltipContent side="bottom">Mark as done</TooltipContent>
</Tooltip>
)}
{onDone && issue.status === "done" && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => { onDone(); }}
>
<Archive />
</Button>
}
/>
<TooltipContent side="bottom">Archive</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={

View File

@@ -77,13 +77,6 @@ export function IssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
// Auto-switch to manual sort so drag ordering is preserved
const viewState = useIssueViewStore.getState();
if (viewState.sortBy !== "position") {
viewState.setSortBy("position");
viewState.setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};

View File

@@ -102,12 +102,6 @@ export function MyIssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const viewState = myIssuesViewStore.getState();
if (viewState.sortBy !== "position") {
viewState.setSortBy("position");
viewState.setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};

View File

@@ -134,11 +134,6 @@ function ProjectIssuesContent({
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const viewState = projectViewStore.getState();
if (viewState.sortBy !== "position") {
viewState.setSortBy("position");
viewState.setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = { status: newStatus };
if (newPosition !== undefined) updates.position = newPosition;
updateIssueMutation.mutate(

View File

@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -19,8 +20,34 @@ import (
// It passes the full daemon environment so credential helpers (e.g. gh) can
// locate their config, and disables TTY prompting so auth failures produce
// clear errors instead of blocking on a non-existent terminal.
//
// safe.directory=* is set via GIT_CONFIG_* env vars so git trusts all
// directories regardless of ownership. The daemon manages its own bare
// caches and worktrees, so the ownership check adds no security value
// and breaks CI environments where the runner UID differs from the
// directory owner.
func gitEnv() []string {
return append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
base := os.Environ()
// Find the existing GIT_CONFIG_COUNT so we append at the next index
// rather than overwriting any env-scoped git config (auth, URL
// rewrites, extra headers, etc.).
existing := 0
for _, e := range base {
if strings.HasPrefix(e, "GIT_CONFIG_COUNT=") {
if n, err := strconv.Atoi(strings.TrimPrefix(e, "GIT_CONFIG_COUNT=")); err == nil {
existing = n
}
}
}
idx := strconv.Itoa(existing)
return append(base,
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_COUNT="+strconv.Itoa(existing+1),
"GIT_CONFIG_KEY_"+idx+"=safe.directory",
"GIT_CONFIG_VALUE_"+idx+"=*",
)
}
// RepoInfo describes a repository to cache.

View File

@@ -44,6 +44,65 @@ func TestGitEnv(t *testing.T) {
if !foundHome {
t.Error("gitEnv() must include HOME from os.Environ()")
}
// Must set safe.directory=* via GIT_CONFIG env vars.
envHas := func(env []string, want string) bool {
for _, e := range env {
if e == want {
return true
}
}
return false
}
if !envHas(env, "GIT_CONFIG_KEY_0=safe.directory") {
t.Error("gitEnv() must include GIT_CONFIG_KEY_0=safe.directory (no pre-existing config)")
}
if !envHas(env, "GIT_CONFIG_VALUE_0=*") {
t.Error("gitEnv() must include GIT_CONFIG_VALUE_0=*")
}
}
func TestGitEnvPreservesExistingConfig(t *testing.T) {
// GIT_CONFIG_COUNT env vars are process-wide; cannot use t.Setenv in
// parallel tests, so run sequentially.
t.Setenv("GIT_CONFIG_COUNT", "2")
t.Setenv("GIT_CONFIG_KEY_0", "url.https://github.com/.insteadOf")
t.Setenv("GIT_CONFIG_VALUE_0", "gh:")
t.Setenv("GIT_CONFIG_KEY_1", "http.extraHeader")
t.Setenv("GIT_CONFIG_VALUE_1", "Authorization: Bearer tok")
env := gitEnv()
envHas := func(want string) bool {
for _, e := range env {
if e == want {
return true
}
}
return false
}
// safe.directory must be appended at index 2 (next available).
if !envHas("GIT_CONFIG_COUNT=3") {
t.Error("expected GIT_CONFIG_COUNT=3")
}
if !envHas("GIT_CONFIG_KEY_2=safe.directory") {
t.Error("expected GIT_CONFIG_KEY_2=safe.directory")
}
if !envHas("GIT_CONFIG_VALUE_2=*") {
t.Error("expected GIT_CONFIG_VALUE_2=*")
}
// Original entries must still be present.
if !envHas("GIT_CONFIG_KEY_0=url.https://github.com/.insteadOf") {
t.Error("existing GIT_CONFIG_KEY_0 was lost")
}
if !envHas("GIT_CONFIG_VALUE_0=gh:") {
t.Error("existing GIT_CONFIG_VALUE_0 was lost")
}
if !envHas("GIT_CONFIG_KEY_1=http.extraHeader") {
t.Error("existing GIT_CONFIG_KEY_1 was lost")
}
}
func TestBareDirName(t *testing.T) {

View File

@@ -17,6 +17,13 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
// sanitizeNullBytes removes null bytes (0x00) from strings.
// PostgreSQL rejects null bytes in text columns with
// "invalid byte sequence for encoding UTF8: 0x00 (SQLSTATE 22021)".
func sanitizeNullBytes(s string) string {
return strings.ReplaceAll(s, "\x00", "")
}
// --- Response structs ---
type SkillResponse struct {
@@ -289,13 +296,13 @@ func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
params.Name = pgtype.Text{String: sanitizeNullBytes(*req.Name), Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
params.Description = pgtype.Text{String: sanitizeNullBytes(*req.Description), Valid: true}
}
if req.Content != nil {
params.Content = pgtype.Text{String: *req.Content, Valid: true}
params.Content = pgtype.Text{String: sanitizeNullBytes(*req.Content), Valid: true}
}
if req.Config != nil {
config, _ := json.Marshal(req.Config)
@@ -323,8 +330,8 @@ func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
for _, f := range req.Files {
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: f.Path,
Content: f.Content,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(f.Content),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
@@ -1188,8 +1195,8 @@ func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) {
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: req.Path,
Content: req.Content,
Path: sanitizeNullBytes(req.Path),
Content: sanitizeNullBytes(req.Content),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())

View File

@@ -37,9 +37,9 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{
WorkspaceID: input.WorkspaceID,
Name: input.Name,
Description: input.Description,
Content: input.Content,
Name: sanitizeNullBytes(input.Name),
Description: sanitizeNullBytes(input.Description),
Content: sanitizeNullBytes(input.Content),
Config: config,
CreatedBy: input.CreatorID,
})
@@ -51,8 +51,8 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
for _, f := range input.Files {
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: f.Path,
Content: f.Content,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(f.Content),
})
if err != nil {
return SkillWithFilesResponse{}, err