mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 21:09:56 +02:00
Compare commits
10 Commits
agent/j/e6
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a5ef0aa8 | ||
|
|
b8907dda8d | ||
|
|
6cd49e132d | ||
|
|
a6db465e46 | ||
|
|
965561a6cc | ||
|
|
163f34f918 | ||
|
|
2317533da4 | ||
|
|
d81e6a14a6 | ||
|
|
e198a67f8f | ||
|
|
0ed16fc1b1 |
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -63,9 +63,13 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -64,10 +64,14 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
@@ -24,8 +24,14 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading)
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
// web wrapper uses useAuthStore((s) => s.user/isLoading). Keep the real
|
||||
// sanitizeNextUrl so the redirect-sanitization rules are exercised rather
|
||||
// than silently drifting behind a mock reimplementation.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
@@ -36,7 +42,7 @@ vi.mock("@multica/core/auth", () => {
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
);
|
||||
return { useAuthStore };
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
|
||||
// Mock auth-cookie
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
@@ -25,8 +25,9 @@ function LoginPageContent() {
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
// the user's workspace list.
|
||||
const nextUrl = searchParams.get("next");
|
||||
// the user's workspace list. Sanitize first so a crafted `?next=https://evil`
|
||||
// cannot bounce the user off-origin after a successful login.
|
||||
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
|
||||
86
apps/web/app/auth/callback/page.test.tsx
Normal file
86
apps/web/app/auth/callback/page.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchParams: new URLSearchParams(),
|
||||
mockLoginWithGoogle: vi.fn(),
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Preserve the real sanitizeNextUrl so the "drop unsafe ?next=" behavior is
|
||||
// exercised rather than silently diverging from the source of truth.
|
||||
vi.mock("@multica/core/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ loginWithGoogle: mockLoginWithGoogle }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: mockListWorkspaces,
|
||||
googleLogin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import CallbackPage from "./page";
|
||||
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(undefined);
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
|
||||
mockSearchParams.set("state", "next:https://evil.example");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
||||
});
|
||||
|
||||
it("honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -42,7 +42,9 @@ function CallbackContent() {
|
||||
const stateParts = state.split(",");
|
||||
const isDesktop = stateParts.includes("platform:desktop");
|
||||
const nextPart = stateParts.find((p) => p.startsWith("next:"));
|
||||
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
|
||||
// Strip "next:" prefix, then drop anything that isn't a safe relative path
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ services:
|
||||
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { createAuthStore } from "./store";
|
||||
export type { AuthStoreOptions, AuthState } from "./store";
|
||||
export { sanitizeNextUrl } from "./utils";
|
||||
|
||||
import type { createAuthStore as CreateAuthStoreFn } from "./store";
|
||||
|
||||
|
||||
45
packages/core/auth/utils.test.ts
Normal file
45
packages/core/auth/utils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeNextUrl } from "./utils";
|
||||
|
||||
describe("sanitizeNextUrl", () => {
|
||||
it("accepts single-slash relative paths", () => {
|
||||
expect(sanitizeNextUrl("/issues")).toBe("/issues");
|
||||
expect(sanitizeNextUrl("/invite/123")).toBe("/invite/123");
|
||||
expect(sanitizeNextUrl("/issues?tab=assigned#top")).toBe(
|
||||
"/issues?tab=assigned#top",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for null or empty input", () => {
|
||||
expect(sanitizeNextUrl(null)).toBeNull();
|
||||
expect(sanitizeNextUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects absolute URLs", () => {
|
||||
expect(sanitizeNextUrl("https://evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("http://evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects javascript: and other non-http schemes", () => {
|
||||
// Caught by the leading-slash rule, but named here so future edits
|
||||
// to the regex don't silently drop protection against this vector.
|
||||
expect(sanitizeNextUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(sanitizeNextUrl("data:text/html,<script>")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs", () => {
|
||||
expect(sanitizeNextUrl("//evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("//evil.example/path")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing backslashes", () => {
|
||||
expect(sanitizeNextUrl("/\\evil.example")).toBeNull();
|
||||
expect(sanitizeNextUrl("\\\\evil.example")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects paths containing control characters", () => {
|
||||
expect(sanitizeNextUrl("/safe\u0000bad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\tbad")).toBeNull();
|
||||
expect(sanitizeNextUrl("/safe\r\nbad")).toBeNull();
|
||||
});
|
||||
});
|
||||
20
packages/core/auth/utils.ts
Normal file
20
packages/core/auth/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Validate a post-login redirect URL and return it only if safe to follow.
|
||||
*
|
||||
* Only single-slash relative paths (e.g. `/invite/abc`) are accepted. Returns
|
||||
* `null` for unsafe or empty input — call sites decide the fallback so this
|
||||
* helper never overloads a specific path with "user did not pass next".
|
||||
*
|
||||
* Rejects:
|
||||
* - `null` / empty string
|
||||
* - absolute URLs (`https://evil.com`, `javascript:alert(1)`, …)
|
||||
* - protocol-relative URLs (`//evil.com`)
|
||||
* - paths containing backslashes (Windows-style or `/\\host`)
|
||||
* - paths containing ASCII control characters (`\x00`–`\x1f`)
|
||||
*/
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface RuntimeDevice {
|
||||
name: string;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
provider: string;
|
||||
launch_header: string;
|
||||
status: "online" | "offline";
|
||||
device_info: string;
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@@ -178,6 +178,7 @@ export function AgentDetail({
|
||||
{activeTab === "custom_args" && (
|
||||
<CustomArgsTab
|
||||
agent={agent}
|
||||
runtimeDevice={runtimeDevice}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
@@ -29,9 +29,11 @@ function entriesToArgs(entries: ArgEntry[]): string[] {
|
||||
|
||||
export function CustomArgsTab({
|
||||
agent,
|
||||
runtimeDevice,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
runtimeDevice?: RuntimeDevice;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<ArgEntry[]>(
|
||||
@@ -69,6 +71,8 @@ export function CustomArgsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const launchHeader = runtimeDevice?.launch_header;
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -77,9 +81,17 @@ export function CustomArgsTab({
|
||||
Custom Arguments
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Additional CLI arguments appended to the agent command at launch
|
||||
(e.g. --model claude-sonnet-4-20250514)
|
||||
Additional CLI arguments appended to the agent command at launch.
|
||||
Supported flags depend on the agent's CLI.
|
||||
</p>
|
||||
{launchHeader && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Launch mode:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{launchHeader} <your args>
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -99,7 +111,7 @@ export function CustomArgsTab({
|
||||
<Input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, e.target.value)}
|
||||
placeholder="--model claude-sonnet-4-20250514"
|
||||
placeholder="--flag value"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -52,9 +52,9 @@ function formatDate(date: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
|
||||
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
|
||||
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
|
||||
running: { label: "Running", color: "text-blue-500", icon: Loader2, spin: true },
|
||||
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
|
||||
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
@@ -66,7 +66,7 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
|
||||
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color, cfg.spin && "animate-spin")} />
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
|
||||
@@ -34,11 +34,16 @@ export function useChatResize(
|
||||
if (!parent) return;
|
||||
|
||||
const update = () => {
|
||||
boundsRef.current = {
|
||||
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
|
||||
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
|
||||
};
|
||||
setBoundsReady(true);
|
||||
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
|
||||
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
|
||||
setBoundsReady(true); // idempotent once true
|
||||
// Only trigger a re-render if the bounds actually changed. Without this
|
||||
// guard, any spurious ResizeObserver notification (including sub-pixel
|
||||
// layout jitter during mount) schedules a setState that feeds back into
|
||||
// the observer, producing "Maximum update depth exceeded".
|
||||
const prev = boundsRef.current;
|
||||
if (prev.maxW === maxW && prev.maxH === maxH) return;
|
||||
boundsRef.current = { maxW, maxH };
|
||||
setRevision((r) => r + 1);
|
||||
};
|
||||
|
||||
|
||||
@@ -697,9 +697,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0">
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{issue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
|
||||
@@ -78,6 +78,16 @@ import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
|
||||
// Stable empty arrays for query defaults. Using an inline `= []` default on
|
||||
// `useQuery` creates a new array reference on every render when `data` is
|
||||
// undefined (e.g. query disabled or loading) — which in turn breaks any
|
||||
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
|
||||
// re-render loops when the effect itself calls `setState`.
|
||||
const EMPTY_PINS: PinnedItem[] = [];
|
||||
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
|
||||
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
|
||||
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
|
||||
|
||||
// Nav items reference WorkspacePaths method names so they can be resolved
|
||||
// against the current workspace slug at render time (see AppSidebar body).
|
||||
// Only parameterless paths are valid nav destinations.
|
||||
@@ -202,11 +212,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
const logout = useLogout();
|
||||
const workspace = useCurrentWorkspace();
|
||||
const p = useWorkspacePaths();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
|
||||
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
|
||||
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
|
||||
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
const { data: inboxItems = EMPTY_INBOX } = useQuery({
|
||||
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
||||
queryFn: () => api.listInbox(),
|
||||
enabled: !!wsId,
|
||||
@@ -216,7 +226,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
const { data: pinnedItems = EMPTY_PINS } = useQuery({
|
||||
...pinListOptions(wsId ?? "", userId ?? ""),
|
||||
enabled: !!wsId && !!userId,
|
||||
});
|
||||
|
||||
@@ -309,7 +309,8 @@ function Start-LocalInstall {
|
||||
Write-Host ""
|
||||
Write-Host " multica setup self-host " -NoNewline; Write-Host "# Configure + authenticate + start daemon" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
Write-Host " Default verification code: 888888"
|
||||
Write-Host " Login: configure RESEND_API_KEY in .env for email codes,"
|
||||
Write-Host " or set APP_ENV=development in .env to enable the dev master code 888888."
|
||||
Write-Host ""
|
||||
Write-Host " To stop all services:"
|
||||
Write-Host ' $env:MULTICA_MODE="stop"; irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex'
|
||||
|
||||
@@ -337,7 +337,8 @@ run_with_server() {
|
||||
printf "\n"
|
||||
printf " ${CYAN}multica setup self-host${RESET} # Configure + authenticate + start daemon\n"
|
||||
printf "\n"
|
||||
printf " Default verification code: ${BOLD}888888${RESET}\n"
|
||||
printf " ${BOLD}Login:${RESET} configure ${CYAN}RESEND_API_KEY${RESET} in .env for email codes,\n"
|
||||
printf " or set ${CYAN}APP_ENV=development${RESET} in .env to enable the dev master code ${BOLD}888888${RESET}.\n"
|
||||
printf "\n"
|
||||
printf " ${BOLD}To stop all services:${RESET}\n"
|
||||
printf " curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop\n"
|
||||
|
||||
@@ -58,6 +58,27 @@ func clearTasks(t *testing.T, issueID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// latestTriggerCommentID returns the trigger_comment_id of the most recently
|
||||
// created queued/dispatched task for the given issue, or empty string if none.
|
||||
func latestTriggerCommentID(t *testing.T, issueID string) string {
|
||||
t.Helper()
|
||||
var triggerID *string
|
||||
err := testPool.QueryRow(context.Background(),
|
||||
`SELECT trigger_comment_id::text
|
||||
FROM agent_task_queue
|
||||
WHERE issue_id = $1 AND status IN ('queued', 'dispatched')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
issueID).Scan(&triggerID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to fetch trigger_comment_id: %v", err)
|
||||
}
|
||||
if triggerID == nil {
|
||||
return ""
|
||||
}
|
||||
return *triggerID
|
||||
}
|
||||
|
||||
// getAgentID returns the ID of the first agent in the test workspace.
|
||||
func getAgentID(t *testing.T) string {
|
||||
t.Helper()
|
||||
@@ -228,6 +249,25 @@ func TestCommentTriggerOnComment(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Regression guard for #1301: the assignee on_comment path must record
|
||||
// the NEW reply as trigger_comment_id, not the thread root. Otherwise
|
||||
// the daemon feeds stale content to the agent prompt, which with
|
||||
// `--resume` sessions surfaces as "already replied, no further action".
|
||||
// Reply placement (flat-thread grouping) is handled downstream in
|
||||
// TaskService.createAgentComment, not here.
|
||||
t.Run("reply records new comment id (not thread root) as trigger_comment_id", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
threadID := postCommentAsAgent(t, issueID, "First pass analysis.", agentID, nil)
|
||||
replyID := postComment(t, issueID, "Please also check the edge case", strPtr(threadID))
|
||||
if n := countPendingTasks(t, issueID); n != 1 {
|
||||
t.Fatalf("expected 1 pending task, got %d", n)
|
||||
}
|
||||
if got := latestTriggerCommentID(t, issueID); got != replyID {
|
||||
t.Errorf("trigger_comment_id = %q, want reply id %q (thread root was %q)",
|
||||
got, replyID, threadID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply to member thread without mentions suppresses trigger", func(t *testing.T) {
|
||||
clearTasks(t, issueID)
|
||||
// Member starts a thread.
|
||||
|
||||
@@ -263,14 +263,12 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
|
||||
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
||||
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
|
||||
// Resolve thread root: if the comment is a reply, agent should reply
|
||||
// to the thread root (matching frontend behavior where all replies
|
||||
// in a thread share the same top-level parent).
|
||||
replyTo := comment.ID
|
||||
if comment.ParentID.Valid {
|
||||
replyTo = comment.ParentID
|
||||
}
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, replyTo); err != nil {
|
||||
// Always use the current comment as the trigger so the agent reads
|
||||
// the actual new reply, not the thread root. Reply placement (flat
|
||||
// thread grouping) is handled downstream by createAgentComment,
|
||||
// which resolves parent_id to the thread root before posting. This
|
||||
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
|
||||
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
|
||||
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,26 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type AgentRuntimeResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
DaemonID *string `json:"daemon_id"`
|
||||
Name string `json:"name"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
Provider string `json:"provider"`
|
||||
Status string `json:"status"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
DaemonID *string `json:"daemon_id"`
|
||||
Name string `json:"name"`
|
||||
RuntimeMode string `json:"runtime_mode"`
|
||||
Provider string `json:"provider"`
|
||||
LaunchHeader string `json:"launch_header"`
|
||||
Status string `json:"status"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
Metadata any `json:"metadata"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
LastSeenAt *string `json:"last_seen_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
@@ -39,19 +41,20 @@ func runtimeToResponse(rt db.AgentRuntime) AgentRuntimeResponse {
|
||||
}
|
||||
|
||||
return AgentRuntimeResponse{
|
||||
ID: uuidToString(rt.ID),
|
||||
WorkspaceID: uuidToString(rt.WorkspaceID),
|
||||
DaemonID: textToPtr(rt.DaemonID),
|
||||
Name: rt.Name,
|
||||
RuntimeMode: rt.RuntimeMode,
|
||||
Provider: rt.Provider,
|
||||
Status: rt.Status,
|
||||
DeviceInfo: rt.DeviceInfo,
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
UpdatedAt: timestampToString(rt.UpdatedAt),
|
||||
ID: uuidToString(rt.ID),
|
||||
WorkspaceID: uuidToString(rt.WorkspaceID),
|
||||
DaemonID: textToPtr(rt.DaemonID),
|
||||
Name: rt.Name,
|
||||
RuntimeMode: rt.RuntimeMode,
|
||||
Provider: rt.Provider,
|
||||
LaunchHeader: agent.LaunchHeader(rt.Provider),
|
||||
Status: rt.Status,
|
||||
DeviceInfo: rt.DeviceInfo,
|
||||
Metadata: metadata,
|
||||
OwnerID: uuidToPtr(rt.OwnerID),
|
||||
LastSeenAt: timestampToPtr(rt.LastSeenAt),
|
||||
CreatedAt: timestampToString(rt.CreatedAt),
|
||||
UpdatedAt: timestampToString(rt.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,3 +125,28 @@ func New(agentType string, cfg Config) (Backend, error) {
|
||||
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
||||
return detectCLIVersion(ctx, executablePath)
|
||||
}
|
||||
|
||||
// launchHeaders maps each supported agent type to the user-visible skeleton
|
||||
// that the daemon spawns before any custom_args are appended. This is
|
||||
// intentionally minimal — only the command + subcommand (or a short mode
|
||||
// label when there is no subcommand). Internal flags, transport values, and
|
||||
// environment variables are deliberately omitted so the string is a hint
|
||||
// about *what* users are extending, not a dump of the full command line.
|
||||
var launchHeaders = map[string]string{
|
||||
"claude": "claude (stream-json)",
|
||||
"codex": "codex app-server",
|
||||
"copilot": "copilot (json)",
|
||||
"cursor": "cursor-agent (stream-json)",
|
||||
"gemini": "gemini (stream-json)",
|
||||
"hermes": "hermes acp",
|
||||
"openclaw": "openclaw agent (json)",
|
||||
"opencode": "opencode run (json)",
|
||||
"pi": "pi (json mode)",
|
||||
}
|
||||
|
||||
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
|
||||
// empty string if the type is unknown. Callers render this as a preview so
|
||||
// users understand which command their custom_args get appended to.
|
||||
func LaunchHeader(agentType string) string {
|
||||
return launchHeaders[agentType]
|
||||
}
|
||||
|
||||
@@ -62,3 +62,28 @@ func TestDetectVersionFailsForMissingBinary(t *testing.T) {
|
||||
t.Fatal("expected error for missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The factory in New() enumerates every supported agent type; LaunchHeader
|
||||
// must stay in sync so the UI preview never shows an empty skeleton for a
|
||||
// runtime the daemon actually spawns. If a new backend is added, add an
|
||||
// entry to launchHeaders in agent.go and extend this list.
|
||||
supported := []string{
|
||||
"claude", "codex", "copilot", "cursor", "gemini",
|
||||
"hermes", "openclaw", "opencode", "pi",
|
||||
}
|
||||
for _, t_ := range supported {
|
||||
if header := LaunchHeader(t_); header == "" {
|
||||
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
if header := LaunchHeader("made-up-agent"); header != "" {
|
||||
t.Errorf("expected empty header for unknown type, got %q", header)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user