Following #1307, the Docker self-host stack defaults to APP_ENV=production,
which disables the 888888 master verification code on auth.go:169. The
installer banners and self-hosting docs still told operators to log in with
888888, leaving them stuck.
Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md,
and self-hosting.mdx to document the three login paths: configure
RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for
private evaluation, or read the dev verification code from backend container
logs. Also warn against enabling APP_ENV=development on public instances.
* fix(agent/codex): route custom_args -m/--model to thread/start payload
Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.
Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.
Fixes#1308.
* fix(ui/agents): drop Codex-incompatible --model example from custom args tab
The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.
Refs #1308.
* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"
This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.
The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.
Refs #1308.
* feat(agent): add LaunchHeader per agent type
Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.
Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.
Refs #1308.
* feat(handler/runtime): surface launch_header on runtime response
runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.
Refs #1308.
* feat(ui/agents): show launch mode preview in custom args tab
Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.
Refs #1308.
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth
Extracts a reusable helper that returns a post-login redirect URL only
when it's a safe single-slash relative path, and null otherwise. Rejects
absolute URLs, protocol-relative URLs, backslashes, and control
characters so call sites can safely pass the result to router.push().
Keeping the rule in a single helper (with direct unit tests) avoids
each consumer re-implementing the validation and drifting.
* fix(auth): validate next= redirect target to prevent open redirect
Closes#1116
Next.js router.push accepts absolute URLs, so a crafted
`/login?next=https://evil.example` would send the user off-origin
after a successful login. The Google OAuth callback has the same
vector via the `state=next:<url>` payload.
Sanitize both entry points through `sanitizeNextUrl` from
`@multica/core/auth` so only safe single-slash relative paths survive;
null results fall through to the existing workspace-list-based default
without any hard-coded path.
---------
Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
* fix(comment): assignee on_comment path should use reply id, not thread root
Symmetric fix to #871 — that PR fixed the @mention path but missed the
assignee on_comment path in the same file. Replies on agent-assigned
issues were still getting trigger_comment_id = parent_id, so the daemon
fed the parent comment's content to the resumed claude session, which
then either exited with 'Already replied to comment <parent>' or silently
misrouted its answer depending on model / session state.
Reply placement (flat-thread grouping) is already decoupled from
trigger_comment_id by TaskService.createAgentComment's parent
normalization (added alongside #871), so passing comment.ID directly is
safe and matches the mention path's post-#871 behavior.
Fixes#1301
Made-with: Cursor
* test(comment): assert assignee on_comment records reply id as trigger_comment_id
Integration regression guard for #1301. Asserts that after a member posts
a reply under an agent-authored thread, the enqueued agent task's
trigger_comment_id matches the new reply, not the thread root. Without
the companion fix in comment.go the old parent-override would store the
root id and the daemon would feed stale content (via prompt.go
BuildPrompt) to the agent.
Made-with: Cursor
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
Agent mentions enqueue a new task; member mentions send a notification.
Without this warning, agents have used `[@Name](mention://agent/<id>)` in
prose (e.g. "GPT-Boy is correct") and accidentally re-triggered the agent.
Adds a caveat under `## Mentions` in the prompt injected into agent
runtimes, plus tightens the Agent bullet to make the side-effect explicit.
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.
Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
When --resume targets a dead session, claude prints
"No conversation found with session ID: ..." to stderr, emits a stream-json
system init with a fresh session_id, then exits with code 1. The backend
was treating that fresh id as the authoritative session, so
daemon.go's retry-with-fresh-session fallback (SessionID == "" guard)
never triggered. Every subsequent task for the same (issue, agent) pair
stayed permanently broken until the server-side session_id was cleared by
hand.
Fix: when --resume was requested but the emitted session_id differs AND
the run failed, drop the fresh id from Result so the daemon's existing
fallback can do its job. Factored into a pure helper and unit-tested.
Fixes#1284
Co-authored-by: fuxiao <fuxiao@zyql.com>
* fix(agent): add per-agent mcp_config field to restore MCP access
Closes#1111
The --strict-mcp-config flag was added defensively in #592 to prevent
Claude agents from inheriting MCP state from the outer Claude Code session.
It was meant to be paired with --mcp-config <path> to inject a controlled
set of MCPs, but that path was never implemented, which silently stripped
all user-scope MCPs from spawned agents.
This PR completes the original design by:
- Adding a nullable mcp_config jsonb column to the agents table
- Wiring mcp_config through AgentResponse, Create/Update requests
- Piping it into ExecOptions.McpConfig in the daemon
- Serializing to a temp file and passing --mcp-config <path> in buildClaudeArgs
- Blocklisting --mcp-config in claudeBlockedArgs to prevent override
via custom_args
Does not touch Codex provider (tracked separately in #674).
Does not implement Multica MCP auto-injection (out of scope).
* fix: disambiguate JSON null vs absent for mcp_config
The release workflow previously triggered on 'v*', which matched a
stray 'v0.2.5-dirty' tag pushed to the repository. GoReleaser ran
again and overwrote the Homebrew formula with a 0.2.5-dirty version
whose tarball URLs 404.
Tighten the trigger to semver-shaped tags and add an explicit guard
that fails the job if the tag name contains '-dirty' (which can come
from 'git describe --tags --dirty').
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Docs site is no longer auto-deployed via Vercel (disabled in
dashboard), so building it on every PR adds friction without
catching anything actionable. Use turbo's negative filter to
skip @multica/docs across all three tasks.
Self-hosted services (postgres, backend, frontend) should restart
automatically on failure or host reboot. This is standard practice
for production docker-compose deployments.
Co-authored-by: Zhazha <zhazha@openclaw.internal>
- Mark AppLink draggable={false} and add pointer-events-none while
dragging, so the browser's native <a> drag (which otherwise navigates
to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
refetch) cannot reorder the DOM under dnd-kit's drop animation.
Mirrors the pattern already used by board-view.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Copilot CLI scans project-level skills from .github/skills/<name>/SKILL.md
(per the official cli-config-dir-reference docs), not from .agent_context/skills/.
Previously, skills injected for the copilot provider were placed under
.agent_context/skills/ and only referenced by name in AGENTS.md, meaning
Copilot would not actually pick them up.
- resolveSkillsDir: add a dedicated copilot case writing to .github/skills/
- Update doc comments in context.go and runtime_config.go
- Add TestWriteContextFilesCopilotNativeSkills covering the new path and
ensuring .agent_context/skills/ is not created for copilot
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
electron-builder 26.8.1 rejects publishingType under the GitHub publisher;
the correct option for selecting draft/prerelease/release is releaseType.
Using publishingType caused schema validation to fail during packaging.
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(agents): make issue tasks easier to open from agent details
Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.
Closes#1129
* fix(agents): use workspace issue paths in tasks tab
* test(agents): cover tasks tab issue links
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
* fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it
The /docs rewrite was running in the default afterFiles slot, which is
evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/
matched /docs first as a workspace named "docs" (which doesn't exist) and
returned 404 before the rewrite to the docs Vercel project ever fired.
Splitting rewrites into beforeFiles/afterFiles puts /docs and
/docs/:path* ahead of route resolution so they always proxy to the docs
zone.
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
Configure the Fumadocs site so it can be served at multica.ai/docs:
- Add basePath: '/docs' to apps/docs/next.config.mjs
- Flatten routes: drop standalone home, render content/docs/index.mdx at
the root, move catch-all from app/docs/[[...slug]] to app/[...slug]
- Wrap children with DocsLayout in the root layout (was a separate
segment-level layout under app/docs/)
- Set source loader baseUrl to '/' so URL slugs no longer carry the
basePath (Next.js prepends it automatically)
- Strip the now-redundant '/docs/' prefix from internal MDX links and
drop the duplicate "Documentation" nav entry
- Add app/not-found.tsx for App Router 404 handling
Wire up multi-zone routing so apps/web proxies /docs/* to the docs app:
- Add DOCS_URL env (default http://localhost:4000) and rewrites for
/docs and /docs/:path* in apps/web/next.config.ts
- Whitelist DOCS_URL in turbo.json globalEnv
Before this PR, `EnsureDaemonID(profile)` wrote to ~/.multica/profiles/
<profile>/daemon.id — meaning the same physical machine minted a different
UUID per profile. On any host running both the CLI-spawned daemon (default
profile) and the desktop-spawned daemon (profile derived from API host),
that produced two runtime rows per provider per workspace. The server-side
`legacy_daemon_ids` merge only covers hostname variants, not UUIDs, so the
rows just piled up.
Profile boundaries are about which backend/account the daemon is talking
to, not about the physical machine. Identity should be per-machine, token
should be per-profile.
Changes:
- `EnsureDaemonID` now always reads/writes ~/.multica/daemon.id regardless
of the `profile` argument. The argument is retained for migration-only
use (see promotion below).
- Migration path: when the canonical file is missing and the requested
profile has a pre-change per-profile daemon.id, promote that UUID in
place so a user who only ever ran under a named profile keeps the same
identity instead of minting a fresh UUID and round-tripping a merge.
- New `LegacyDaemonUUIDs()` scans ~/.multica/profiles/*/daemon.id and
returns every UUID that survives parsing. `config.go` now appends those
to the daemon's `legacy_daemon_ids` payload, so any runtime rows
previously registered under a per-profile UUID (on any backend) get
merged into the canonical machine UUID at register time.
Tests replace the `ProfileIsolated` assertion with `SharedAcrossProfiles`
and add coverage for promotion, UUID scanning (including skipping corrupt
files), and the empty-profiles-dir fast path.
Adds two new toggleable card properties that surface issue context at a glance:
- Project: shows the parent project icon + title when the issue belongs to one.
- Sub-issue progress: gates the existing progress ring behind a card property
so users can hide it when not useful.
Both default to on; toggled via the existing "Display" popover.
* feat(daemon): persistent UUID identity + legacy-id merge at register-time
daemon_id is now a stable UUID persisted to `<profile-dir>/daemon.id` on
first start, replacing the hostname-derived id that drifted whenever
`.local` appeared/disappeared, a system was renamed, or a profile
switched — each of which used to mint a fresh `agent_runtime` row and
strand agents on the old one.
To migrate existing installs without operator intervention, the daemon
reports every legacy id it may have registered under previously
(`host`, `host` with `.local` stripped, and `host[-profile]` variants
for both). At register-time the server looks up each candidate row
scoped to (workspace, provider), re-points its agents and tasks onto
the new UUID-keyed row, records which legacy id was subsumed in the
new `legacy_daemon_id` column for audit, and deletes the stale row.
Result: users running `xxx.local`-keyed runtimes today transparently
land on the new UUID row on next daemon restart.
The hostname-prefix `MigrateAgentsToRuntime` / `daemon_id LIKE '...-%'`
compatibility shim is no longer needed and has been removed along with
the handler call that invoked it.
* fix(daemon): handle bidirectional .local drift and case drift in legacy merge
Review on #1220 flagged two gaps in the legacy-id migration candidate set:
1. Reverse .local: LegacyDaemonIDs only added the stripped variant when the
current hostname ended in `.local`. The opposite direction — DB has
`foo.local`, current host is `foo` — was missed, so runtimes registered
under the `.local` variant stayed orphaned after upgrade. Now both
variants (`foo` and `foo.local`) are always emitted, regardless of what
`os.Hostname()` currently returns, plus their `-<profile>` suffix forms.
2. Case drift: os.Hostname() has been observed returning different casings
on the same machine across mDNS/reboot state. A case-sensitive `=`
comparison stranded rows like `Jiayuans-MacBook-Pro.local` when the
daemon later reported `jiayuans-macbook-pro.local`. FindLegacyRuntimeByDaemonID
now uses `LOWER(daemon_id) = LOWER(@daemon_id)` on both sides, so casing
differences merge rather than orphan. The (workspace_id, provider) prefix
still bounds the scan to a tiny set of rows so the non-indexed LOWER()
comparison has negligible cost.
Tests: TestLegacyDaemonIDs gets the mixed-case + reverse-direction cases;
daemon_test.go adds TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal
and TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift.
* fix(daemon): consolidate every case-duplicate legacy runtime, not just the first
Follow-up review on #1220: after switching to `LOWER(daemon_id) =
LOWER(@daemon_id)`, the single-row lookup still only merged one legacy
row per candidate. If a machine already had two rows in the DB that
differed only in casing (e.g. `Jiayuans-MacBook-Pro.local` AND
`jiayuans-macbook-pro.local` coexisting because earlier hostname drift
already minted a duplicate), only one of them got consolidated and the
other stayed orphaned — violating the "no duplicate runtime per machine
after backfill" acceptance.
- FindLegacyRuntimeByDaemonID → FindLegacyRuntimesByDaemonID (:many)
- mergeLegacyRuntimes iterates every returned row and dedupes across
overlapping legacy candidates so `foo` and `foo.local` both resolving
to the same stored row don't double-process
Test: TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes seeds two
case-duplicate rows with one agent each and confirms both rows are
deleted and both agents end up on the new UUID-keyed row.
These trigger kinds exist in the DB schema but nothing on the server
fires them:
- autopilot_scheduler.ClaimDueScheduleTriggers filters kind='schedule'
(pkg/db/queries/autopilot.sql:150)
- DispatchAutopilot is reached only from the scheduler (source:schedule)
or POST /api/autopilots/{id}/trigger (source:manual); no inbound
webhook or api endpoint exists
- The UI only surfaces schedule creation
Exposing them in the CLI lets users create triggers that sit in the DB
doing nothing. Drop --kind from trigger-add, require --cron, always
send kind=schedule. Re-add the flag when the server grows a dispatch
path for the other kinds.
Follow-up to #1249. Two small follow-ups requested in review:
1. `resolveTaskWorkspaceID` was duplicated between `handler/daemon.go` and
`service/task.go`. #1249 fixed the handler copy but left both in place,
meaning any future branch (e.g. a fourth task link type) still needs
to be added in two files. Promote the service method to the exported
`TaskService.ResolveTaskWorkspaceID` and delete the handler copy.
Handler's `requireDaemonTaskAccess` and `ListTaskMessagesByUser` now
call through `h.TaskService`.
2. Add a regression test `TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace`
covering the exact scenario from #1224: a task linked only via
`AutopilotRunID` must resolve to the autopilot's workspace. The test
asserts 404 for a cross-workspace daemon token and 200 (with status
transitioning to `running`) for the correct-workspace token.
Follow-up to #1192. Document the v2 protocol contract that the
dispatch-level threadId guard relies on, and lock down the two leakage
paths the guard closes:
- turn/completed from a subagent thread must not call onTurnDone
- item/completed (agentMessage, final_answer) from a subagent thread
must neither leak text into the output builder nor terminate the turn
Without these tests a future refactor that drops or relocates the guard
would not be caught by CI, since existing notification tests omit the
top-level threadId field and pass through unfiltered.
* feat(cli): add autopilot commands
Expose the existing autopilot REST API through the multica CLI so
users and agents can list, get, create, update, delete, trigger, and
inspect autopilots, plus manage their triggers (schedule/webhook/api).
Also surface the read + core write commands in the agent meta skill
prompt so agents discover them without needing --help.
- new cmd_autopilot.go (+ test) wiring /api/autopilots endpoints
- add APIClient.PatchJSON (autopilot update uses PATCH)
- expose autopilot in CORE COMMANDS group
- extend runtime_config.go meta skill with autopilot entries
- document autopilot command group in CLI_AND_DAEMON.md
* fix(autopilot): address code review — restrict run_only, validate workspace on update
Code review caught two issues with the initial CLI PR:
1. run_only mode is broken end-to-end. The daemon-side
resolveTaskWorkspaceID() in internal/handler/daemon.go only resolves
workspace from issue/chat, so run_only tasks (which have neither)
return 404 from /start. BuildPrompt() would also emit an empty issue
ID. The service-level resolver in internal/service/task.go already
handles AutopilotRunID, but the daemon endpoint uses the handler
copy. Fixing that path is out of scope for the CLI PR; drop
run_only from the CLI and docs so we don't recommend a mode that
cannot complete. Server continues to accept it for the existing UI.
2. UpdateAutopilot did not verify that a new assignee_id belongs to
the workspace, unlike CreateAutopilot. This let a PATCH swap in an
agent from a different workspace. Mirror the same
GetAgentInWorkspace check.
The route-level loading.tsx creates a Suspense boundary that shows a
generic skeleton on every page navigation within the dashboard. Since
every page already handles its own data-loading skeleton via TanStack
Query isLoading, this causes two sequential skeleton flashes:
loading.tsx skeleton → page skeleton → content.
Removing it makes the old page stay visible during route transitions
(typically <100ms), then the new page renders directly with its own
skeleton — a single, smooth transition.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(daemon): filter thread/status/changed by threadId to prevent subagent interference
When Codex CLI has memories enabled, the app-server spawns a memory
consolidation subagent as a separate thread within the same stdio
connection. When that subagent thread finishes and transitions to idle,
the daemon's codex backend mistakenly interprets the idle signal as the
main turn completing, causing it to close stdin and cancel the context
before the real turn produces any output.
Add a threadId check to the thread/status/changed handler so only
status changes from the tracked thread trigger turn completion. Signals
from subagent threads (threadId != c.threadID) are now ignored.
Fixes#1181
* fix(codex): dispatch-level threadId filter for subagent notifications
Codex multiplexes subagent threads (e.g. memory consolidation) on
the same stdio pipe. Previously only thread/status/changed had a
threadId guard, but item/completed (agentMessage + final_answer),
turn/completed, and turn/started from subagent threads could still
trigger onTurnDone or contaminate output.
Move the threadId check to the top of handleRawNotification so all
notification handlers are protected. Remove the now-redundant
per-handler check on thread/status/changed.
Fixesmultica-ai/multica#1181
---------
Co-authored-by: fuxiao <fuxiao@zyql.com>
resolveTaskWorkspaceID only handled tasks linked via IssueID or
ChatSessionID. Tasks created by run_only autopilots (introduced in
#1028) have only AutopilotRunID set, so the resolver returned an empty
workspace ID, causing requireDaemonTaskAccess to respond with 404.
Add an AutopilotRunID branch that looks up the autopilot run, then
its parent autopilot, to obtain the workspace ID.
The project CRUD commands (list, get, create, update, delete, status)
and the `--project` flag on issue commands have been implemented in
the CLI but were not yet documented. Add them to both the docs site
reference and the repo-level CLI_AND_DAEMON.md so the feature is
discoverable.
Closes MUL-867
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make the http/https scheme allowlist structurally enforced instead of a
convention. Move the allowlist check + shell.openExternal call into a
single openExternalSafely wrapper in external-url.ts, have both main-process
call sites (the IPC handler and setWindowOpenHandler) go through it, and
add an ESLint no-restricted-syntax rule that bans direct shell.openExternal
usage anywhere under apps/desktop/src/main/ except external-url.ts itself.
This is the follow-up to #1124: same safety guarantee, but a reviewer can
no longer accidentally reintroduce a bare shell.openExternal somewhere that
bypasses the check — the lint rule catches it at CI time. Also restores
the scheme info in the warn log (lost when the helper was extracted).
Test coverage extended to the cases the original PR review flagged but
didn't ship: casing (FILE:// / HTTPS://), javascript: / data:, ftp / smb,
vscode:// / ms-msdt:, mailto / tel, credentials-in-URL, empty / malformed.
Added two openExternalSafely tests (electron mocked) confirming allowed
URLs forward and rejected URLs do not.
Closes a follow-up bullet from the internal #1115 / #1124 review.
* fix(desktop): restrict shell.openExternal to http/https schemes
The Electron main-process IPC handler for shell:openExternal called
shell.openExternal with whatever string the renderer passed, with no
scheme validation. Under this app's intentional webSecurity: false and
sandbox: false configuration (#648), any unsafe content path in the
renderer reaching this IPC becomes a way to dispatch arbitrary OS
protocol handlers — file://, smb://, vscode://, Windows ms-msdt:,
and so on.
Parse the URL and reject anything outside http/https (the only schemes
any legitimate call site uses today). Matches the Electron security
checklist guidance for openExternal on non-isolated renderers.
Closes#1115
* Close the desktop external-open gap on target=_blank links
The original fix validated only the IPC path, but the renderer could still trigger shell.openExternal through setWindowOpenHandler for target="_blank" links and window.open(). This change reuses one allowlist helper for both sinks and adds a focused unit test for the helper contract.
Constraint: Desktop shell.openExternal must stay limited to http/https despite webSecurity=false and sandbox=false
Rejected: Duplicate URL validation logic in each sink | easy to drift and harder to test
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep all desktop external-open paths on the same validator so new sinks do not bypass the allowlist
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop test -- src/main/external-url.test.ts
Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop typecheck
Not-tested: Full desktop app manual smoke run
Related: #1115
---------
Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
* fix(daemon): platform-aware Codex sandbox config to unbreak macOS network
On macOS, Codex's Seatbelt sandbox in workspace-write mode silently
ignores '[sandbox_workspace_write] network_access = true' (see
openai/codex#10390). That blocks DNS inside the sandbox, so 'multica
issue get' and other CLI calls fail with 'dial tcp: lookup ...: no such
host' — this is what caused MUL-963.
Changes:
- New server/internal/daemon/execenv/codex_sandbox.go: picks a sandbox
policy based on runtime.GOOS and the detected Codex CLI version.
Non-darwin or darwin with a known-fixed version keeps workspace-write
+ network_access=true; older darwin falls back to danger-full-access
and logs a warn with upgrade hint. The fix-version threshold is a
single constant (CodexDarwinNetworkAccessFixedVersion) so it's easy
to bump once upstream ships.
- Per-task config.toml now gets a 'multica-managed' marker block
(BEGIN/END comments) rewritten idempotently; user-owned keys outside
the markers are preserved. Legacy inline sandbox directives from
earlier daemon versions are stripped on migration.
- execenv.PrepareParams gains CodexVersion; execenv.Reuse takes a
codexVersion arg; daemon.go caches detected versions at registration
and threads them through to Prepare/Reuse.
- Replaces the old ensureCodexNetworkAccess tests with
platform-parameterised coverage (linux vs darwin, idempotency,
legacy-migration, policy matrix).
- docs/codex-sandbox-troubleshooting.md: symptom fingerprint table,
decision matrix, self-check commands, trade-offs.
Refs: MUL-963
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(daemon): hoist managed sandbox block above user tables (MUL-963)
Review on #1246 flagged that upsertMulticaManagedBlock appended the
managed block to EOF. If the user's config.toml ends inside a TOML table
(e.g. [permissions.multica] or [profiles.foo]), a trailing bare
sandbox_mode = "..." is parsed as a key of that preceding table, so
Codex silently ignores the policy the daemon meant to apply.
Two changes make the block position-independent:
- renderMulticaManagedBlock now emits only top-level key=value lines and
uses TOML dotted-key form (sandbox_workspace_write.network_access =
true) instead of opening a [sandbox_workspace_write] header. The block
therefore neither inherits from nor leaks into any surrounding table.
- upsertMulticaManagedBlock always hoists the block to the top of the
file (stripping any previously written managed block first), so the
sandbox_mode line is always at the TOML root regardless of what the
user put below it. This also migrates configs written by the original
PR #1246 logic where the block was trapped behind a user table.
Added tests for the regression scenario (pre-existing [permissions.*]
table) and the legacy-trailing-block migration; updated the existing
Linux default test and the troubleshooting runbook to reflect the
dotted-key form.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Autopilot was formatting the triggered-at timestamp with time.RFC3339
(e.g. "2026-04-16T14:54:32Z"), which is hard to read and confusing for
users in non-UTC timezones because the "Z" suffix looks like an error
instead of a timezone indicator.
Switch to a human-readable format ("2026-04-16 14:54 UTC") so only the
hour differs from local time; minutes match across timezones, making
the value easy to reconcile at a glance.
Fixesmultica-ai/multica#1197.
Shared inbox links (?issue=<id>) pointed to notifications that may no
longer exist in the current user's inbox (archived, or received by
someone else). The detail pane would fall back to an empty state and
leave the user stuck.
After inbox loads, if the selected key has no matching item, replace
the URL with /issues/<id> so the link still resolves to something
meaningful.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
crypto.randomUUID() is only defined in secure contexts, so self-hosted
HTTP deployments were throwing TypeError on mount and when clicking Add.
Route the id generation through the existing createSafeId() helper so
the tab works in non-secure contexts too.
Fixes#1214
- Migration 046 adds UNIQUE(workspace_id, name) with dedup (keep most recently updated)
- CreateAgent handler returns 409 Conflict scoped to constraint name agent_workspace_name_unique
- Dedup verified as (0 rows) against worktree DB; rerun against staging/production before applying
- Down migration drops the constraint only; deleted rows and cascaded data are not restored
Co-authored-by: Anup Joy <joyanup@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Desktop-launched daemons have their CLI binary overwritten by the Desktop
app on every launch, so any in-app update is reset. The detail panel already
renders 'Managed by Desktop' and hides the Update button when
metadata.launched_by === 'desktop', but the sidebar red dot
(useMyRuntimesNeedUpdate) and the list arrow (useUpdatableRuntimeIds) still
flagged them because runtimeNeedsUpdate() only considered mode/owner/version.
Short-circuit runtimeNeedsUpdate() on launched_by === 'desktop' so all three
surfaces (sidebar dot, list arrow, detail panel) agree and defer CLI
upgrades to the Desktop auto-updater.
Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Our CLI release flow pre-creates a *published* GitHub Release via
`gh release create`. electron-builder's default `publishingType: draft`
conflicts with `existingType=release` and causes the DMG/ZIP/blockmaps/
latest-mac.yml uploads to be silently skipped, which breaks
electron-updater auto-update on installed clients (observed on v0.2.4,
had to fall back to `gh release upload` manually).
Explicitly setting `publishingType: release` aligns electron-builder
with our release flow so desktop artifacts are uploaded to the existing
published release automatically.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(desktop): tabs are per-workspace, not cross-workspace
Tabs are now grouped by workspace in the store; the TabBar shows only the
active workspace's tabs, and switching workspace swaps the visible group.
Before this change tabs were a flat list that spanned workspaces, which
produced a confusing experience: working in acme with three tabs, then
switching to butter and back, still showed whatever tabs you happened to
open while you were in butter alongside your acme work.
The bug had the same shape as the pre-workspace-overlay bug we fixed in
#1237 — a concept ("workspace") was encoded in data (tab paths) but
ignored by the UI that displayed it (TabBar). The fix is structural:
make the data model match the concept.
Key changes:
- **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`.
The invariant "every tab belongs to a workspace group" is enforced at
sanitize time and at migration time; there is no longer a root `/`
sentinel.
- **NavigationAdapter** detects cross-workspace pushes and delegates to
`switchWorkspace(slug, path)` instead of navigating the active tab's
router. All existing call sites in shared code (sidebar dropdown,
settings post-delete redirect, invite-accept, cmd+k) keep calling
`push(paths.workspace(x).issues())` unchanged.
- **TabContent** renders only the active workspace's tabs under Activity.
Cross-workspace state preservation is an explicit non-goal — switching
workspaces should feel like switching.
- **WorkspaceRouteLayout** auto-heal no longer navigates the tab router
to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`)
that drops the whole stale group in one go.
- **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the
user has workspaces; the new-workspace overlay opens/closes based on
workspace count independently of any route.
- **Persistence migration** (v1 → v2) groups old flat tabs by extracted
slug, drops root / transition / reserved-slug tabs, and picks an
active workspace from the old active tab's owning group. No data
loss for existing users with workspace-scoped tabs.
Web is unchanged — tabs are a desktop-only concept. `packages/views`,
`packages/core`, `apps/web` are all untouched. `setCurrentWorkspace`
in core remains the single source of truth for the API client's
workspace header, driven by `WorkspaceRouteLayout` as before.
Tests: 19 tab-store tests (sanitize, migration, switchWorkspace,
validate, close-last-reseeds, reset). 38 desktop tests total pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: stable selectors + defensive guards on tab-store
Addresses self-review findings on #1239.
**C1 — perf cliff from unstable selector returns.** The previous
`useActiveTab()` selector used `.find()` inside, so every router tick
on the active tab (which replaces the Tab object via immutable spread
in updateTab / updateTabHistory) forced every subscriber to re-render.
Replaced with finer-grained selectors:
- `useActiveTabIdentity()` — { slug, tabId } primitives (stable across
unrelated updates).
- `useActiveTabRouter()` — stable object reference for a tab's lifetime.
- `useActiveTabHistory()` — { historyIndex, historyLength } numbers.
`useTabHistory` and `DesktopNavigationProvider` now consume the
primitive selectors, so back/forward buttons don't churn on every
path change. A non-hook `getActiveTab(state)` helper covers the
event-handler case.
**I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in
case a malformed path ever reaches the adapter's detector.
**I2 — merge warns on path/slug mismatch.** Previously silent drop;
now `console.warn` makes the condition visible during debugging.
**Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab
object eliminates a redundant store read per rendered tab.
Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts`
still uses `window.location.assign` for workspace-deleted eviction —
that's a full renderer reload on desktop, which post-refactor wastes
the careful in-memory tab state we just set up. Fixing cleanly requires
a navigation-callback injection pattern through CoreProvider, which is
cross-cutting and deserves its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError
Symptom: deleting the current workspace logged "current workspace
deleted, switching" from the realtime handler and surfaced an
"Uncaught (in promise) CancelledError" from TanStack Query's
refetchQueries batch.
Root cause: a three-way race between the mutation's own
invalidateQueries(workspaceKeys.list()), the settings page's
navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime
workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery.
All three refetched the same query concurrently; TanStack Query
cancelled the in-flight loser(s), and the rejection bubbled out of
invalidateQueries as an unhandled promise rejection.
Fix: invert the order. Compute the destination from the current
cached workspace list, navigate immediately, *then* fire the
mutation. By the time the backend fires workspace:deleted, the
active workspace is already something else — the realtime handler's
"current === deleted" check fails and its relocate branch no-ops.
Only one refetch happens (the mutation's onSettled), no race, no
cancellation.
navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery
since it reads from cache and returns before the mutation fires.
Applies to both Leave and Delete flows. Both web and desktop benefit
since the code is in packages/views.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): clear workspace singleton + flex drag strip + defer seeding
Three issues that the last round of delete-workspace fixes missed.
**1. `setCurrentWorkspace` singleton leaks after delete.** Navigating
before the mutation (prior fix) changed the URL but nothing cleared
the core platform's currentSlug/currentWsId singleton. Three
downstream consumers still believed the deleted workspace was active:
- `useRealtimeSync`'s `workspace:deleted` handler: its
`getCurrentWsId() === deleted` check fired, triggering a parallel
relocate that raced the mutation's invalidate and the settings
page's navigate — CancelledError + `window.location.assign`
(white screen reload).
- Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the
sidebar mounted, and `useWorkspaceId` inside it threw because the
workspace was gone from the list cache.
- API client's `X-Workspace-Slug` header: stale on the next call.
Fix: `navigateAwayFromCurrentWorkspace` now calls
`setCurrentWorkspace(null, null)` before pushing. The next
workspace's `WorkspaceRouteLayout` re-sets the singleton when it
mounts; for the last-workspace case, null is the correct state
(overlay has no workspace context).
Same family as the previous logout bug: persist only writes to
storage, reset on logout must also wipe in-memory state. Here the
singleton is another in-memory bit that survives a URL change if
we don't explicitly clear it.
**2. "Cannot update a component while rendering" warning.** The
per-workspace-tabs refactor kept the validate+seed call in render
phase (matching the pre-refactor pattern). It worked before because
`validateWorkspaceSlugs` is idempotent; the new `switchWorkspace`
seed is not, and triggers a TabBar re-render during AppContent's
render. Moved to `useLayoutEffect` — synchronously after render,
before paint, no flicker.
**3. Welcome-screen drag region didn't work on desktop.** The
absolute-positioned `h-10 z-10` drag strip relied on z-index stacking
to beat the content wrapper's no-drag for hit-testing, which wasn't
reliable for `-webkit-app-region` on the overlay. Replaced with a
flex child (`h-12 shrink-0` at top of the overlay's flex-col), so
the drag region owns its own layout space — any pixel in the top 48
is unambiguously drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split
Codifies the lessons from the recent desktop refactor series
(#1237, #1238, #1239) so future work doesn't re-derive them from
bugs. Covers:
- **Route categories** (session / transition / error) — explains why
`/workspaces/new` and `/invite/:id` are overlay state, not routes,
on desktop; stale slugs auto-heal instead of rendering error pages.
- **`setCurrentWorkspace` singleton hygiene** — unmount doesn't
clear it; any code leaving workspace context must call
`setCurrentWorkspace(null, null)` explicitly.
- **Workspace destructive operations ordering** — navigate first,
mutate after, to avoid the three-way refetch race that surfaces
as CancelledError + full-page reload.
- **Tab isolation** — tabs are grouped per workspace; cross-workspace
push is intercepted by the navigation adapter and translated into
switchWorkspace.
- **Drag region pattern** — flex child at top, not absolute overlay;
`-webkit-app-region` hit-testing is unreliable with z-index stacking.
- **UX vs platform chrome split** — UX affordances (Back, Log out,
welcome copy) in packages/views/; platform chrome (drag, immersive
mode, tab system) in desktop-only code.
Also patches the Cross-Platform Development Rules' rule #2 which
previously said "add a route in both apps" unconditionally — added
the exception for pre-workspace transition flows pointing at the
new Desktop-specific Rules section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(editor): prevent duplicate image attachments showing as file cards
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(editor): rewrite bubble menu with @floating-ui/dom for reliable scroll hiding
Replace Tiptap's native <BubbleMenu> with custom @floating-ui/dom positioning.
The native plugin's virtual element lacked contextElement, so the hide middleware
could only detect viewport clipping — not nested scroll container clipping
(e.g. comment/reply inputs inside a scrollable page).
Key changes:
- contextElement on virtual reference enables hide middleware to detect ALL
overflow ancestor clipping, not just viewport bounds
- visibility:hidden (not display:none) keeps element measurable for
computePosition, fixing the comma-selection positioning bug
- autoUpdate monitors all scroll ancestors automatically via contextElement
- Remove: getScrollParent, scrollHiddenRef, manual scroll listener, scrollTarget
- Remove @tiptap/extension-bubble-menu dependency (no longer used)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(workspace): typed delete confirm + sole-owner leave preflight
Harden the Danger Zone on the Workspace settings tab.
**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.
**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
- sole member: "Delete the workspace to leave."
- sole owner with other members: "Promote another member to owner
first, or delete the workspace."
Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* review: gate Danger Zone on members fetched + reset typed input on rename
Addresses self-review findings on #1238:
- Previously the Danger Zone rendered immediately with `members = []`, so
the Delete workspace block (gated on `isOwner`, which is derived from
an empty members list) would flash in once the query settled. Gate the
whole section on `membersFetched` so it appears once with correct
controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
the workspace while the dialog is open, the already-typed string stops
matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
mid-dialog clears the input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.
Fix by recognising the underlying category mistake: on desktop, these
flows are application state, not routes. Move them to a window-level
overlay driven by a small Zustand store; the navigation adapter
intercepts pushes to the corresponding paths and routes them to the
overlay instead. Web keeps the routes (users need shareable URLs and
back-button semantics), so shared view components are reused as-is.
UX affordances (Back button when dismissable, Log out escape) live in
the shared NewWorkspacePage/InvitePage so both platforms render
identical content; the desktop overlay is now a thin platform shell
(drag strip + useImmersiveMode) that wraps the shared UX. Web wires
onBack based on whether the user has any workspaces.
Also addresses several related issues uncovered along the way:
- Logout now resets the in-memory tab + overlay stores (previously only
localStorage was cleared, so the next login inherited the prior
user's tabs).
- WorkspaceRouteLayout auto-heals a stale workspace slug by navigating
to "/" instead of rendering NoAccessPage — on desktop without a URL
bar, "no access" is always stale state, not a legitimate destination.
- IndexRedirect overlay lifecycle is bidirectional: opens when wsList
is empty, closes when it becomes non-empty (realtime workspace:added
would otherwise leave the overlay stuck open).
- tryRouteToOverlay resets the current tab to "/" when opening the
new-workspace overlay; otherwise workspace-scoped components under
the overlay continue to render and throw when the workspace they
reference disappears from the cache (reproducible by deleting the
last workspace from Settings).
- handleDeepLink now accepts multica://invite/<id>, IPC'd through to
the renderer and opened as an invite overlay. Email template still
links to https:// (unchanged), but the desktop dispatch path is now
wired for a future "open in desktop app" bridge.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.
Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
where a sibling is already referenced inline — handles old data
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.
Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.