Compare commits

...

32 Commits

Author SHA1 Message Date
Jiang Bohan
2ea778796a feat(daemon): add token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes
Previously only Claude and Codex had log-scanning-level token usage
reporting (Flow B). This adds scanners for the remaining three runtimes:

- OpenCode: reads JSON message files from ~/.local/share/opencode/storage/message/
- OpenClaw: reads JSONL session files from ~/.openclaw/agents/*/sessions/
- Hermes: reads JSONL session files from ~/.hermes/sessions/

All three are registered in Scanner.Scan() and follow the same
(date, provider, model) aggregation pattern as existing scanners.
2026-04-13 13:42:05 +08:00
Bohan Jiang
68b101fe01 Merge pull request #804 from igornumeriano/fix/x-link
fix: update X link to correct handle @MulticaAI
2026-04-13 13:11:34 +08:00
LinYushen
e20c507dcc fix(security): add Content-Security-Policy response header (#822)
Adds CSP middleware to the global middleware chain as a browser-level
defense against XSS: script-src 'self', object-src 'none',
frame-ancestors 'none', base-uri 'self', form-action 'self'.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:53:39 +08:00
LinYushen
95bfd7dd96 feat(auth): migrate auth token to HttpOnly Cookie & WebSocket Origin whitelist (#819)
* feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist

Security improvements from the MUL-566 audit report:

1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
   preventing XSS-based token theft. Cookie-based auth includes CSRF
   protection via double-submit cookie pattern. The Authorization header
   path is preserved for Electron desktop app and CLI/PAT clients.

2. WebSocket upgrader now validates the Origin header against a
   configurable allowlist (ALLOWED_ORIGINS env var), rejecting
   connections from unauthorized origins.

Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync

1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
   preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
   and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:13:35 +08:00
Igor Numeriano
3bf7f467a2 fix: update X (Twitter) link to correct handle @MulticaAI
The previous link pointed to https://x.com/multica_hq which returns
a 404. The correct handle is @MulticaAI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:38:27 -03:00
Jiayuan Zhang
04238bea22 chore: simplify v0.1.27 changelog — merge related items, remove trivial entries (#803) 2026-04-13 02:58:22 +08:00
Bohan Jiang
c13d365015 Merge pull request #796 from bulai0408/fix/codex-sandbox-network-access
fix(agent): enable network access for Codex sandbox
2026-04-13 01:55:12 +08:00
bulai0408
47eb6cb612 fix(agent): enable network access for Codex sandbox so Multica CLI can reach API
Codex tasks running in workspace-write sandbox mode could not resolve
api.multica.ai because the hardcoded sandbox parameter in thread/start
overrode any config.toml settings, and the default sandbox policy blocks
network access.

Changes:
- Remove hardcoded `sandbox: "workspace-write"` from thread/start RPC —
  let Codex read sandbox config from its own config.toml instead
- Auto-generate config.toml in per-task CODEX_HOME with
  `sandbox_mode = "workspace-write"` and `network_access = true`,
  preserving any existing user settings
- Fix Reuse() to restore CodexHome for Codex provider on workdir reuse

Closes #368
2026-04-13 01:03:43 +08:00
Bohan Jiang
1ee4e0501a fix(handler): add .claude/skills/ candidate path for skills.sh import (#792)
Skills stored under .claude/skills/{name}/SKILL.md (the Claude Code
native discovery convention) were not found during skills.sh import,
causing a 502 error. Add this path to the candidate list.

Fixes #777
2026-04-12 23:39:46 +08:00
Bohan Jiang
544b9bc971 docs: add v0.1.27 changelog entry (2026-04-12) (#790) 2026-04-12 23:35:48 +08:00
tianrking
0c19f0d16f Fix workspace filter sync and align CLI docs (#722)
* Fix workspace filter sync and align CLI docs

* simplify workspace sync subscription in issues page

* docs(self-hosting): align supported agents and daemon env vars
2026-04-12 23:11:40 +08:00
Bohan Jiang
d74d7f2b7b fix(handler): add cycle detection to BatchUpdateIssues parent_issue_id handling (#788)
BatchUpdateIssues was missing the ancestor-walk cycle detection that
single UpdateIssue has. This allowed creating circular parent
relationships (e.g. A→B→A) via the batch API. Added the same
depth-limited walk (up to 10 ancestors) to detect and skip issues
that would create cycles, consistent with UpdateIssue behavior.
2026-04-12 23:03:46 +08:00
Qiaochu Hu
0c2102b951 fix(handler): fix batch operations and error handling bugs (#779)
fix(handler): fix batch operations and error handling bugs
2026-04-12 23:00:40 +08:00
zerion-925
0c28d3cd08 fix: add randomUUID fallback for non-secure contexts (#749)
* fix: fallback when crypto.randomUUID is unavailable

* fix(core): remove Math.random UUID fallback and add tests

---------

Co-authored-by: Zerion <dev@take-app.local>
2026-04-12 22:51:56 +08:00
Jiang Bohan
7312b5650c fix(server): fix ListRuntimeUsage to filter by date range instead of row count (#765)
Replace LIMIT $2 with AND date >= $2 in ListRuntimeUsage query. When a
runtime uses multiple models each day has multiple rows, so a row LIMIT
silently returns fewer days than requested.

Also fixes displayName warnings in issue-detail test mocks and adds
missing setOpen to useCallback deps in search-command.

Co-authored-by: jayavibhavnk <jaya11vibhav@gmail.com>
Closes #731
2026-04-12 22:46:07 +08:00
Jiayuan Zhang
c7e0863419 fix(auth): preserve last workspace ID across re-login (#772)
The logout handler was clearing `multica_workspace_id` from storage,
so re-login always defaulted to the first workspace. The workspace ID
is a user preference, not session-sensitive data — keep it so both
web and desktop restore the correct workspace after re-authentication.

Also pass `lastWorkspaceId` in the desktop login page, which was
previously missing.
2026-04-12 21:33:19 +08:00
Jiayuan Zhang
d7c83bc285 fix(sanitize): preserve code blocks and inline code from HTML entity escaping (#774)
Bluemonday operates on raw text, so characters like && and <> inside
markdown code blocks/inline code were being HTML-escaped (e.g. && → &amp;&amp;),
causing them to render incorrectly in the frontend.

Now extracts fenced code blocks and inline code spans before sanitization,
runs bluemonday on the remaining content, then restores the code verbatim.
2026-04-12 21:32:35 +08:00
Jiayuan Zhang
4285549381 fix(views): navigate to issue in same tab instead of opening new tab (#773)
Issue mention clicks now use push() for same-tab navigation, matching
AppLink behavior. Cmd/Ctrl+Click still opens in a new tab on desktop.
2026-04-12 17:29:54 +08:00
Bohan Jiang
9ed80120e0 fix(views): add missing useFileDropZone and FileDropOverlay mocks in create-issue test (#768)
The create-issue modal started importing useFileDropZone and FileDropOverlay
from the editor module, but the test mock was not updated to include them,
causing CI to fail.
2026-04-12 15:18:15 +08:00
Manish Chauhan
ec586ebc25 fix(pins): scope cache by user and fix sidebar pin action (#664) 2026-04-12 15:02:20 +08:00
Bohan Jiang
ea8cb18f9e Merge pull request #639 from jyf2100/agent/agent/e7cb5f8c
test(web): cover issue creation flow regressions
2026-04-12 14:17:47 +08:00
Bohan Jiang
d011039c58 fix(sweeper): add error logging and dedup for issue reset (#762)
- Log a warning when HasActiveTaskForIssue fails, matching the existing
  pattern for UpdateIssueStatus errors. Silent failures here make
  debugging DB issues unnecessarily difficult.
- Track processed issues to skip redundant GetIssue + HasActiveTaskForIssue
  queries when multiple tasks for the same issue are swept in one cycle.
2026-04-12 14:13:07 +08:00
Gabriel Amazonas
471d4a6838 Update required AI agent CLI list in SELF_HOSTING.md (#734)
Added OpenClaw and OpenCode to the list of required AI agent CLIs.
2026-04-12 14:09:57 +08:00
pradeep7127
bd42552854 fix(sweeper): reset in_progress issues to todo after stale task sweep (#747)
fix(sweeper): reset in_progress issues to todo after stale task sweep
2026-04-12 14:08:54 +08:00
Bohan Jiang
31eeb00b59 fix(storage): clean up variable shadowing and dead code (#761)
- Rename `filepath` local var to `dest` in LocalStorage.Upload to avoid
  shadowing the path/filepath package import
- Remove unused detectContentType and overrideContentType functions from
  util.go (no longer needed after ServeFile switched to http.ServeFile)
2026-04-12 14:06:46 +08:00
Antar Das
d32c419b6d feat(storage): add local file storage fallback (#710)
* feat(storage): add local file storage fallback

- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions

* ♻️ refactor(storage): improve path handling and file serving

switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.
2026-04-12 14:04:22 +08:00
Jiayuan Zhang
f31a322978 chore: add issue templates and improve PR template (#759)
* chore: add issue templates and improve PR template

Add GitHub issue templates (bug report, feature request) using YAML
forms, referencing hermes-agent's template structure. Update the PR
template with clearer sections for changes made, related issues, and
a more comprehensive checklist.

* chore: add AI disclosure section to PR template

Since most PRs are now authored or co-authored by AI coding tools,
add a dedicated AI Disclosure section to the PR template. Includes
authorship type, tool used, and a human review checklist to ensure
AI-generated code is properly reviewed before merge.

* chore: simplify AI disclosure to focus on prompt sharing

Remove the review-status checklist — it was too heavy and users won't
actually do it. Instead focus on what's useful: which AI tool was used
and what prompt/approach produced the code, so the team can learn from
each other's AI workflows.

* chore: simplify issue templates to lower submission friction

Bug report: just what happened + steps to reproduce (required),
plus an optional context field for logs/env.

Feature request: just what you want and why (required),
plus an optional proposed solution.

Removed all dropdowns, environment fields, checkboxes, and
other fields that discourage users from filing issues.

* chore: add screenshots section to issue templates

Add optional screenshots field to both bug report and feature request
templates so users can attach images for richer context.
2026-04-12 13:58:18 +08:00
Jiayuan Zhang
5bae3368d7 feat(landing): add install command copy block to hero section (#743)
Adds a terminal-style one-click copy block below the CTA buttons showing
the curl install command, with a copy-to-clipboard button that shows a
checkmark on success.
2026-04-12 02:42:05 +08:00
Jiayuan Zhang
f100b5b707 fix(auth): graceful email degradation for self-hosting (#742)
* fix(auth): log email send errors and gracefully degrade in non-production

In non-production environments (APP_ENV != "production"), if sending the
verification code email fails, log the error as a warning and still return
success. This lets self-hosting users log in with the master code (888888)
even when their Resend configuration is incomplete (e.g. unverified from-domain).

In production, the behavior is unchanged — email failures return 500.

Also adds guidance in .env.example about RESEND_FROM_EMAIL for self-hosters.

Closes #723

* fix(auth): remove APP_ENV degradation, keep error logging only

Remove the APP_ENV-based graceful degradation for email send failures
— it's risky if users forget to set APP_ENV=production. Instead, always
return 500 on email failure (safe for production) and rely on the error
log (slog.Error) with the actual Resend error for debugging.

Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
2026-04-12 02:30:01 +08:00
Jiayuan Zhang
701399536f feat(cli): enhance version command with JSON output and build info (#740)
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:18:08 +08:00
Jiayuan Zhang
4ca607f888 chore: remove Apache 2.0 license badge from READMEs (#739) 2026-04-12 02:11:45 +08:00
roc
a35f71f65d test(web): cover issue creation flow regressions 2026-04-10 15:52:43 +08:00
85 changed files with 2987 additions and 235 deletions

View File

@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -40,6 +42,16 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

39
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,26 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,34 +1,56 @@
## What
## What does this PR do?
<!-- What does this PR do? Keep it to 1-3 sentences. -->
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
- [ ] Other (describe below)
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## Checklist
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure (optional)
## AI Disclosure
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

View File

@@ -11,6 +11,7 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:

View File

@@ -135,6 +135,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -169,6 +172,12 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server

View File

@@ -136,12 +136,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
---
@@ -155,12 +155,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -190,10 +190,11 @@ multica:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:

View File

@@ -18,10 +18,9 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**

View File

@@ -18,7 +18,6 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)

View File

@@ -71,12 +71,16 @@ Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install multica
```
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
### b) One-command setup

View File

@@ -66,6 +66,21 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.

View File

@@ -2,6 +2,8 @@ import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
@@ -11,6 +13,7 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string {
const DEFAULT_PATH = "/issues";
function createId(): string {
return crypto.randomUUID();
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {

View File

@@ -76,12 +76,15 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one AI agent CLI:
If the agents list is empty, install at least one supported AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
Then restart the daemon:

View File

@@ -88,6 +88,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -122,6 +125,12 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server

View File

@@ -72,12 +72,16 @@ The daemon runs on your local machine (not inside Docker). It detects installed
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install multica
```
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
### b) One-command setup
@@ -211,6 +215,21 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.

View File

@@ -12,6 +12,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
cookieAuth
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -52,6 +53,8 @@ export function LandingHero() {
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
@@ -87,6 +90,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

View File

@@ -277,6 +277,28 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.1.27",
date: "2026-04-12",
title: "One-Click Setup, Self-Hosting & Stability",
changes: [],
features: [
"One-click install & setup — `curl | bash` installs CLI, `--local` bootstraps full self-hosting, `multica setup` auto-detects local server",
"Self-hosted storage — local file fallback when S3 is unavailable, plus custom S3 endpoint support (MinIO)",
"Inline property editing (priority, status, lead) on project list page",
],
improvements: [
"Stale agent tasks auto-swept; agent live card shows immediately without waiting for first message",
"Comment attachments uploaded via CLI now visible in the UI",
"Pinned items scoped per user with fixed sidebar pin action",
],
fixes: [
"Workspace ownership checks on daemon API routes and attachment uploads",
"Markdown sanitizer preserves code blocks from HTML entity escaping",
"Next.js upgraded to ^16.2.3 for CVE-2026-23869",
"OpenClaw backend rewritten to match actual CLI interface",
],
},
{
version: "0.1.24",
date: "2026-04-11",

View File

@@ -277,6 +277,28 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.1.27",
date: "2026-04-12",
title: "一键安装、自部署与稳定性",
changes: [],
features: [
"一键安装与配置——`curl | bash` 安装 CLI`--local` 完整自部署,`multica setup` 自动检测本地服务器",
"自部署存储——无 S3 时本地文件存储回退,支持自定义 S3 端点MinIO",
"项目列表页支持行内编辑属性(优先级、状态、负责人)",
],
improvements: [
"过期 Agent 任务自动清扫;执行卡片立即显示,无需等待首条消息",
"通过 CLI 上传的评论附件现在可在 UI 中显示",
"置顶项按用户隔离,修复侧边栏置顶操作",
],
fixes: [
"Daemon API 路由和附件上传新增工作区所有权校验",
"Markdown 清洗器保留代码块不被 HTML 实体转义",
"Next.js 升级至 ^16.2.3 修复 CVE-2026-23869",
"OpenClaw 后端重写以匹配实际 CLI 接口",
],
},
{
version: "0.1.24",
date: "2026-04-11",

13
e2e/env.ts Normal file
View File

@@ -0,0 +1,13 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { config } from "dotenv";
const envCandidates = [".env.worktree", ".env"];
for (const filename of envCandidates) {
const path = resolve(process.cwd(), filename);
if (existsSync(path)) {
config({ path });
break;
}
}

View File

@@ -4,6 +4,7 @@
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
@@ -21,39 +22,43 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
// Keep each E2E login isolated so previous test runs do not trip the
// per-email send-code rate limit.
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
// Step 2: Read code from database
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email]
[email],
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
body: JSON.stringify({ email, code: result.rows[0].code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
}
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
@@ -64,6 +69,8 @@ export class TestApiClient {
});
}
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
return data;
} finally {
await client.end();

View File

@@ -11,11 +11,14 @@ test.describe("Issues", () => {
});
test.afterEach(async () => {
await api.cleanup();
if (api) {
await api.cleanup();
}
});
test("issues page loads with board view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await expect(page.locator("text=Backlog")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
await expect(page.getByText(title)).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
await page.click("text=New Issue");
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
const title = "E2E Created " + Date.now();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await titleInput.fill(title);
await page.getByRole("button", { name: "Create Issue" }).click();
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("region", { name: /Notifications/ }).getByText(title),
).toBeVisible();
await page.getByRole("button", { name: "View issue" }).click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
});
test("can navigate to issue detail page", async ({ page }) => {
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
).toBeVisible();
});
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
test("can dismiss issue creation", async ({ page }) => {
await page.getByRole("button", { name: "New Issue" }).click();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await page.click("text=Cancel");
await page.keyboard.press("Escape");
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
await expect(titleInput).not.toBeVisible();
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
});
});

View File

@@ -52,6 +52,7 @@ import type {
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -84,10 +85,20 @@ export class ApiClient {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
@@ -108,7 +119,7 @@ export class ApiClient {
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
@@ -167,6 +178,10 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@@ -610,7 +625,7 @@ export class ApiClient {
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });

View File

@@ -8,6 +8,7 @@ export class WSClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private cookieAuth = false;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
@@ -15,19 +16,23 @@ export class WSClient {
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(url: string, options?: { logger?: Logger }) {
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
}
setAuth(token: string, workspaceId: string) {
setAuth(token: string | null, workspaceId: string) {
this.token = token;
this.workspaceId = workspaceId;
}
connect() {
const url = new URL(this.baseUrl);
if (this.token) url.searchParams.set("token", this.token);
// In cookie mode, the browser sends the HttpOnly cookie automatically
// with the WebSocket upgrade request — no token in URL needed.
if (!this.cookieAuth && this.token)
url.searchParams.set("token", this.token);
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);

View File

@@ -7,6 +7,8 @@ export interface AuthStoreOptions {
storage: StorageAdapter;
onLogin?: () => void;
onLogout?: () => void;
/** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */
cookieAuth?: boolean;
}
export interface AuthState {
@@ -22,13 +24,26 @@ export interface AuthState {
}
export function createAuthStore(options: AuthStoreOptions) {
const { api, storage, onLogin, onLogout } = options;
const { api, storage, onLogin, onLogout, cookieAuth } = options;
return create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
if (cookieAuth) {
// In cookie mode, the HttpOnly cookie is sent automatically.
// Try to fetch the current user — if the cookie exists the server will accept it.
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
set({ user: null, isLoading: false });
}
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
@@ -54,8 +69,11 @@ export function createAuthStore(options: AuthStoreOptions) {
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
// Token mode: persist for Electron / legacy.
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
@@ -63,16 +81,21 @@ export function createAuthStore(options: AuthStoreOptions) {
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
},
logout: () => {
if (cookieAuth) {
// Clear server-side HttpOnly cookie.
api.logout().catch(() => {});
}
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useAuthStore } from "../auth";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
@@ -7,16 +8,17 @@ import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -24,22 +26,23 @@ export function useCreatePin() {
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -47,19 +50,20 @@ export function useDeletePin() {
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
});
}

View File

@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
};
export function pinListOptions(wsId: string) {
export function pinListOptions(wsId: string, userId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId),
queryKey: pinKeys.list(wsId, userId),
queryFn: () => api.listPins(),
});
}

View File

@@ -25,6 +25,7 @@ function initCore(
storage: StorageAdapter,
onLogin?: () => void,
onLogout?: () => void,
cookieAuth?: boolean,
) {
if (initialized) return;
@@ -37,13 +38,15 @@ function initCore(
});
setApiInstance(api);
// Hydrate token from storage
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
// In token mode, hydrate token from storage.
if (!cookieAuth) {
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
}
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage, onLogin, onLogout });
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
@@ -60,13 +63,14 @@ export function CoreProvider({
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
storage = defaultStorage,
cookieAuth,
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []);
return (
<QueryProvider>
@@ -76,6 +80,7 @@ export function CoreProvider({
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
cookieAuth={cookieAuth}
>
{children}
</WSProvider>

View File

@@ -8,6 +8,8 @@ export interface CoreProviderProps {
wsUrl?: string;
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
storage?: StorageAdapter;
/** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */
cookieAuth?: boolean;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */

View File

@@ -35,6 +35,8 @@ export interface WSProviderProps {
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
cookieAuth?: boolean;
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
onToast?: (message: string, type?: "info" | "error") => void;
}
@@ -45,6 +47,7 @@ export function WSProvider({
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
@@ -54,10 +57,15 @@ export function WSProvider({
useEffect(() => {
if (!user || !workspace) return;
const token = storage.getItem("multica_token");
if (!token) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
const token = cookieAuth ? null : storage.getItem("multica_token");
if (!cookieAuth && !token) return;
const ws = new WSClient(wsUrl, { logger: createLogger("ws") });
const ws = new WSClient(wsUrl, {
logger: createLogger("ws"),
cookieAuth,
});
ws.setAuth(token, workspace.id);
setWsClient(ws);
ws.connect();
@@ -66,7 +74,7 @@ export function WSProvider({
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage]);
}, [user, workspace, wsUrl, storage, cookieAuth]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };

View File

@@ -102,7 +102,8 @@ export function useRealtimeSync(
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequestId, createSafeId, generateUUID } from "./utils";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("utils id helpers", () => {
it("generateUUID returns a valid UUID v4", () => {
const id = generateUUID();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i;
return arr;
},
});
const id = createSafeId();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createRequestId defaults to length 8 and respects custom length", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
expect(createRequestId()).toBe("12345678");
expect(createRequestId(12)).toBe("123456781234");
});
});

View File

@@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function generateUUID(): string {
const cryptoObj = globalThis.crypto;
if (!cryptoObj?.getRandomValues) {
throw new Error("Secure UUID generation requires crypto.getRandomValues");
}
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
*/
export function createSafeId(): string {
const cryptoObj = globalThis.crypto;
if (cryptoObj?.randomUUID) {
try {
return cryptoObj.randomUUID();
} catch {
// Fall through to fallback.
}
}
return generateUUID();
}
/** Request id helper used for logs/tracing headers. */
export function createRequestId(length = 8): string {
return createSafeId().replace(/-/g, "").slice(0, length);
}

View File

@@ -1,6 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { createSafeId } from "@multica/core/utils";
/** Find and remove a fileCard node by uploadId. */
@@ -109,7 +110,7 @@ export async function uploadAndInsertFile(
}
} else {
// Non-image: insert skeleton fileCard → upload → finalize with real URL
const uploadId = crypto.randomUUID();
const uploadId = createSafeId();
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
const insertContent = { type: "fileCard", attrs: cardAttrs };
if (pos !== undefined) {

View File

@@ -53,7 +53,7 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
@@ -61,11 +61,13 @@ function IssueMention({
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
} else {
window.open(issuePath, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
}
return;
}
push(issuePath);
};
const cardClass =

View File

@@ -86,7 +86,7 @@ function urlTransform(url: string): string {
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const path = `/issues/${issueId}`;
return (
<span
@@ -94,11 +94,13 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(path, label);
} else {
window.open(path, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />

View File

@@ -94,7 +94,10 @@ vi.mock("../../editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
ContentEditor: forwardRef(function MockContentEditor(
{ defaultValue, onUpdate, placeholder }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -116,7 +119,10 @@ vi.mock("../../editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
TitleEditor: forwardRef(function MockTitleEditor(
{ defaultValue, placeholder, onBlur, onChange }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({

View File

@@ -195,6 +195,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const workspace = useWorkspaceStore((s) => s.workspace);
// Issue navigation — read from TQ list cache
@@ -264,7 +265,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();

View File

@@ -38,7 +38,7 @@ export function IssuesPage() {
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
useEffect(() => {
initFilterWorkspaceSync();
initFilterWorkspaceSync((cb) => useWorkspaceStore.subscribe((s) => cb(s.workspace?.id)));
}, []);
useEffect(() => {

View File

@@ -43,6 +43,7 @@ import {
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
@@ -63,7 +64,7 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinKeys } from "@multica/core/pins/queries";
import { pinListOptions } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
@@ -93,7 +94,6 @@ function DraftDot() {
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
const { push } = useNavigation();
useEffect(() => {
if (isDragging) wasDragged.current = true;
@@ -114,12 +114,13 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
>
<SidebarMenuButton
isActive={isActive}
onClick={() => {
render={<AppLink href={href} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
push(href);
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
@@ -129,17 +130,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
<FolderKanban className="size-4 shrink-0" />
)}
<span className="truncate">{label}</span>
<button
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</button>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</SidebarMenuAction>
</SidebarMenuItem>
);
}
@@ -158,6 +159,7 @@ interface AppSidebarProps {
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
@@ -174,10 +176,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
queryFn: () => api.listPins(),
enabled: !!wsId,
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();

View File

@@ -0,0 +1,223 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
},
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: (state: { workspace: { name: string } }) => unknown) => {
const state = { workspace: { name: "Test Workspace" } };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({
useIssueDraftStore: Object.assign(
(selector?: (state: typeof mockDraftStore) => unknown) =>
(selector ? selector(mockDraftStore) : mockDraftStore),
{ getState: () => mockDraftStore },
),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({
api: {},
}));
vi.mock("../editor", () => ({
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
uploadFile: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
);
}),
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
}));
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
StatusPicker: () => <div data-testid="status-picker" />,
PriorityPicker: () => <div data-testid="priority-picker" />,
AssigneePicker: () => <div data-testid="assignee-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
Upload file
</button>
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
custom: mockToastCustom,
dismiss: mockToastDismiss,
error: mockToastError,
},
}));
import { CreateIssueModal } from "./create-issue";
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "Ship create issue regression coverage",
description: undefined,
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-1"));
expect(screen.getByText("Issue created")).toBeInTheDocument();
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View issue" }));
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
});

View File

@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
@@ -193,6 +194,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
export function ProjectDetail({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
@@ -201,7 +203,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const { getActorName } = useActorName();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
const createPin = useCreatePin();
const deletePinMut = useDeletePin();

View File

@@ -228,7 +228,7 @@ export function SearchCommand() {
setOpen(false);
push(href);
},
[push],
[push, setOpen],
);
return (

View File

@@ -1,3 +1,4 @@
import "./e2e/env";
import { defineConfig } from "@playwright/test";
export default defineConfig({

View File

@@ -17,7 +17,7 @@ var updateCmd = &cobra.Command{
}
func runUpdate(_ *cobra.Command, _ []string) error {
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit)
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s, built: %s)\n", version, commit, date)
// Check latest version from GitHub.
latest, err := cli.FetchLatestRelease()

View File

@@ -1,15 +1,42 @@
package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
)
func init() {
versionCmd.Flags().String("output", "text", "Output format: text or json")
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("multica %s (commit: %s)\n", version, commit)
},
RunE: runVersion,
}
func runVersion(cmd *cobra.Command, _ []string) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
info := map[string]string{
"version": version,
"commit": commit,
"date": date,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(info)
}
fmt.Printf("multica %s (commit: %s, built: %s)\n", version, commit, date)
fmt.Printf("go: %s, os/arch: %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
return nil
}

View File

@@ -10,6 +10,7 @@ import (
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
var rootCmd = &cobra.Command{

View File

@@ -56,9 +56,21 @@ func allowedOrigins() []string {
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
// Initialize storage with S3 as primary, fallback to local
var store storage.Storage
s3 := storage.NewS3StorageFromEnv()
if s3 != nil {
store = s3
} else {
local := storage.NewLocalStorageFromEnv()
if local != nil {
store = local
}
}
cfSigner := auth.NewCloudFrontSignerFromEnv()
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
r := chi.NewRouter()
@@ -66,10 +78,16 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger)
r.Use(chimw.Recoverer)
r.Use(middleware.ContentSecurityPolicy)
origins := allowedOrigins()
// Share allowed origins with WebSocket origin checker.
realtime.SetAllowedOrigins(origins)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(),
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
@@ -87,10 +105,19 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
realtime.HandleWebSocket(hub, mc, pr, w, r)
})
// Local file serving (when using local storage)
if local, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, file)
})
}
// Auth (public)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
r.Post("/auth/google", h.GoogleLogin)
r.Post("/auth/logout", h.Logout)
// Daemon API routes (require daemon token or valid user token)
r.Route("/api/daemon", func(r chi.Router) {

View File

@@ -134,12 +134,36 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, bus *events.
}
affectedAgents := make(map[string]pgtype.UUID)
processedIssues := make(map[string]bool)
for _, ft := range items {
// Look up workspace ID from the issue so the event reaches the right WS room.
workspaceID := ""
if issue, err := queries.GetIssue(ctx, ft.IssueID); err == nil {
workspaceID = util.UUIDToString(issue.WorkspaceID)
// If the issue is still in_progress and no other active tasks remain,
// reset it back to todo so the daemon can pick it up again.
issueKey := util.UUIDToString(ft.IssueID)
if issue.Status == "in_progress" && !processedIssues[issueKey] {
processedIssues[issueKey] = true
hasActive, checkErr := queries.HasActiveTaskForIssue(ctx, ft.IssueID)
if checkErr != nil {
slog.Warn("runtime sweeper: failed to check active tasks for issue",
"issue_id", issueKey,
"error", checkErr,
)
} else if !hasActive {
if _, updateErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: ft.IssueID,
Status: "todo",
}); updateErr != nil {
slog.Warn("runtime sweeper: failed to reset stuck issue to todo",
"issue_id", issueKey,
"error", updateErr,
)
}
}
}
}
bus.Publish(events.Event{

View File

@@ -300,6 +300,168 @@ func TestSweepDispatchedStaleTask(t *testing.T) {
}
}
// TestSweepResetsInProgressIssueToTodo verifies the core fix: when the sweeper
// force-fails a stale task whose issue is still in_progress (because the daemon
// crashed mid-run), the issue is reset back to todo so the daemon can re-queue it.
//
// Without this fix the issue stays in_progress permanently — the agent never runs
// to update the status because it was never dispatched.
func TestSweepResetsInProgressIssueToTodo(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
// Use the same agent/runtime as the other sweeper tests.
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create an issue already in in_progress (simulates a daemon crash mid-run).
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Stuck in_progress issue', 'in_progress', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create a stale running task for the issue (3 hours old — beyond any timeout).
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
// Fail the stale task (running timeout of 1 second — our task is 3 hours old).
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
// Confirm our task was swept.
found := false
for _, ft := range failedTasks {
if ft.ID.Bytes == parseUUIDBytes(taskID) {
found = true
break
}
}
if !found {
t.Fatalf("expected task %s to be in failed tasks, got %v", taskID, failedTasks)
}
// This is what we're testing: issue must be reset from in_progress → todo.
broadcastFailedTasks(ctx, queries, bus, failedTasks)
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "todo" {
t.Fatalf("expected issue status 'todo' after sweep, got '%s' — issue is stuck", issueStatus)
}
}
// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets
// issues that are truly stuck in in_progress — it must not clobber issues whose
// agents already moved them forward (e.g. to in_review) before the task timed out.
func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Issue already advanced to in_review by the agent before the task timed out.
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Already in_review issue', 'in_review', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
broadcastFailedTasks(ctx, queries, bus, failedTasks)
// Issue should remain in_review — the sweeper must not clobber agent progress.
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "in_review" {
t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus)
}
}
// parseUUIDBytes converts a UUID string to the 16-byte array used by pgtype.UUID.
func parseUUIDBytes(s string) [16]byte {
s = strings.ReplaceAll(s, "-", "")

View File

@@ -0,0 +1,152 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
"strings"
"time"
)
const (
AuthCookieName = "multica_auth"
CSRFCookieName = "multica_csrf"
authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds
)
func cookieDomain() string {
return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN"))
}
func isSecureCookie() bool {
env := os.Getenv("APP_ENV")
return env == "production" || env == "staging"
}
// generateCSRFToken creates a CSRF token bound to the auth token via HMAC.
// Format: hex(nonce) + "." + hex(HMAC-SHA256(nonce, authToken)).
// This ensures an attacker who can write cookies on a subdomain cannot forge
// a valid CSRF token without knowing the auth token.
func generateCSRFToken(authToken string) (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
nonceHex := hex.EncodeToString(nonce)
mac := hmac.New(sha256.New, []byte(authToken))
mac.Write(nonce)
sig := hex.EncodeToString(mac.Sum(nil))
return nonceHex + "." + sig, nil
}
// SetAuthCookies sets the HttpOnly auth cookie and the readable CSRF cookie on the response.
func SetAuthCookies(w http.ResponseWriter, token string) error {
secure := isSecureCookie()
domain := cookieDomain()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: token,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
csrfToken, err := generateCSRFToken(token)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: csrfToken,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
return nil
}
// ClearAuthCookies removes the auth and CSRF cookies.
func ClearAuthCookies(w http.ResponseWriter) {
domain := cookieDomain()
secure := isSecureCookie()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// ValidateCSRF checks the X-CSRF-Token header against the auth cookie.
// The CSRF token is HMAC-signed with the auth token, so the server verifies
// the signature rather than simply comparing cookie == header.
// Returns true if validation passes (including for safe methods that don't need CSRF).
func ValidateCSRF(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
return false
}
authCookie, err := r.Cookie(AuthCookieName)
if err != nil || authCookie.Value == "" {
return false
}
parts := strings.SplitN(csrfHeader, ".", 2)
if len(parts) != 2 {
return false
}
nonce, err := hex.DecodeString(parts[0])
if err != nil {
return false
}
expectedSig, err := hex.DecodeString(parts[1])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(authCookie.Value))
mac.Write(nonce)
return hmac.Equal(mac.Sum(nil), expectedSig)
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
)
// Directories to symlink from the shared ~/.codex/ into the per-task CODEX_HOME.
@@ -66,6 +67,12 @@ func prepareCodexHome(codexHome string, logger *slog.Logger) error {
}
}
// Ensure config.toml has workspace-write sandbox with network access enabled.
// Codex needs network access to reach the Multica API (api.multica.ai).
if err := ensureCodexNetworkAccess(filepath.Join(codexHome, "config.toml")); err != nil {
logger.Warn("execenv: codex-home ensure network access failed", "error", err)
}
return nil
}
@@ -137,6 +144,54 @@ func ensureSymlink(src, dst string) error {
return os.Symlink(src, dst)
}
// defaultCodexConfig is the minimal config.toml for Codex tasks.
// It sets workspace-write sandbox mode with network access enabled so the
// Multica CLI can reach api.multica.ai.
const defaultCodexConfig = `sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
`
// ensureCodexNetworkAccess ensures that config.toml exists and contains the
// sandbox_workspace_write section with network_access = true. If the file
// doesn't exist, it creates one with defaults. If it exists but lacks the
// network_access setting, the section is appended.
func ensureCodexNetworkAccess(configPath string) error {
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
// No config.toml — create with defaults.
return os.WriteFile(configPath, []byte(defaultCodexConfig), 0o644)
}
if err != nil {
return fmt.Errorf("read config.toml: %w", err)
}
content := string(data)
// If the file already has network_access configured under sandbox_workspace_write, leave it alone.
if strings.Contains(content, "[sandbox_workspace_write]") && strings.Contains(content, "network_access") {
return nil
}
// Append the section. If sandbox_mode is already set, only append the section block.
var appendStr string
if strings.Contains(content, "[sandbox_workspace_write]") {
// Section exists but missing network_access — append the key under it.
content = strings.Replace(content, "[sandbox_workspace_write]", "[sandbox_workspace_write]\nnetwork_access = true", 1)
return os.WriteFile(configPath, []byte(content), 0o644)
}
// Section doesn't exist — append both sandbox_mode (if missing) and the section.
appendStr = "\n"
if !strings.Contains(content, "sandbox_mode") {
appendStr += "sandbox_mode = \"workspace-write\"\n"
}
appendStr += "\n[sandbox_workspace_write]\nnetwork_access = true\n"
return os.WriteFile(configPath, append(data, []byte(appendStr)...), 0o644)
}
// copyFileIfExists copies src to dst. If src doesn't exist, it's a no-op.
// If dst already exists, it's not overwritten.
func copyFileIfExists(src, dst string) error {

View File

@@ -140,6 +140,18 @@ func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger
logger.Warn("execenv: refresh context files failed", "error", err)
}
// Restore CodexHome for Codex provider — the per-task codex-home directory
// lives alongside the workdir. Re-run prepareCodexHome to ensure config
// (especially network access) is up to date.
if provider == "codex" {
codexHome := filepath.Join(env.RootDir, "codex-home")
if err := prepareCodexHome(codexHome, logger); err != nil {
logger.Warn("execenv: refresh codex-home failed", "error", err)
} else {
env.CodexHome = codexHome
}
}
logger.Info("execenv: reusing env", "workdir", workDir)
return env
}

View File

@@ -664,10 +664,14 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
t.Errorf("config.json content = %q", data)
}
// config.toml should be copied.
// config.toml should be copied and have network access appended.
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
if string(data) != `model = "o3"` {
t.Errorf("config.toml content = %q", data)
tomlStr := string(data)
if !strings.Contains(tomlStr, `model = "o3"`) {
t.Errorf("config.toml missing original model setting, got: %q", tomlStr)
}
if !strings.Contains(tomlStr, "network_access = true") {
t.Errorf("config.toml missing network_access, got: %q", tomlStr)
}
// instructions.md should be copied.
@@ -689,17 +693,25 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// Directory should only contain the sessions symlink (no auth.json, no config.json, etc.).
// Directory should contain sessions symlink + auto-generated config.toml.
entries, err := os.ReadDir(codexHome)
if err != nil {
t.Fatalf("failed to read codex-home: %v", err)
}
if len(entries) != 1 {
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
entryNames := make(map[string]bool, len(entries))
for _, e := range entries {
entryNames[e.Name()] = true
}
if !entryNames["sessions"] {
t.Error("expected sessions symlink")
}
if !entryNames["config.toml"] {
t.Error("expected config.toml (auto-generated for network access)")
}
for name := range entryNames {
if name != "sessions" && name != "config.toml" {
t.Errorf("unexpected entry: %s", name)
}
t.Errorf("expected only sessions symlink in codex-home, got: %v", names)
}
// sessions should be a symlink to the shared sessions dir.
sessionsPath := filepath.Join(codexHome, "sessions")
@@ -712,6 +724,176 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
}
}
func TestEnsureCodexNetworkAccessCreatesDefault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config.toml: %v", err)
}
s := string(data)
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("missing sandbox_mode")
}
if !strings.Contains(s, "[sandbox_workspace_write]") {
t.Error("missing [sandbox_workspace_write] section")
}
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
}
func TestEnsureCodexNetworkAccessPreservesExisting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
existing := `model = "o3"
[sandbox_workspace_write]
network_access = true
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Errorf("config should be unchanged, got:\n%s", data)
}
}
func TestEnsureCodexNetworkAccessAppendsToExisting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
existing := `model = "o3"
sandbox_mode = "workspace-write"
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, `model = "o3"`) {
t.Error("lost existing model setting")
}
if !strings.Contains(s, "[sandbox_workspace_write]") {
t.Error("missing [sandbox_workspace_write] section")
}
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
}
func TestEnsureCodexNetworkAccessAddsMissingKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
// Section exists but without network_access.
existing := `[sandbox_workspace_write]
allow_commands = ["git"]
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
if !strings.Contains(s, `allow_commands = ["git"]`) {
t.Error("lost existing allow_commands")
}
}
func TestPrepareCodexHomeEnsuresNetworkAccess(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Empty shared home — no config.toml to copy.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// config.toml should be created with network access defaults.
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not created: %v", err)
}
s := string(data)
if !strings.Contains(s, "network_access = true") {
t.Error("config.toml missing network_access = true")
}
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("config.toml missing sandbox_mode")
}
}
func TestReuseRestoresCodexHome(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
// First, Prepare a codex env.
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-reuse",
TaskID: "e5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.CodexHome == "" {
t.Fatal("expected CodexHome to be set after Prepare")
}
// Reuse should restore CodexHome.
reused := Reuse(env.WorkDir, "codex", TaskContextForEnv{IssueID: "reuse-test"}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if reused.CodexHome == "" {
t.Fatal("expected CodexHome to be restored after Reuse")
}
// Verify config.toml has network access.
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not found in reused CodexHome: %v", err)
}
if !strings.Contains(string(data), "network_access = true") {
t.Error("reused config.toml missing network_access = true")
}
}
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()

View File

@@ -0,0 +1,173 @@
package usage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"time"
)
// scanHermes reads Hermes JSONL session files from
// ~/.hermes/sessions/*.jsonl
// and extracts token usage from assistant message and usage_update entries.
//
// Hermes communicates via the ACP (Agent Communication Protocol) and logs
// session events as JSONL. Token usage appears in:
// - "assistant" messages with a "usage" field
// - "usage_update" notification entries with cumulative token snapshots
func (s *Scanner) scanHermes() []Record {
root := hermesSessionRoot()
if root == "" {
return nil
}
// Glob for session files: sessions/*.jsonl
pattern := filepath.Join(root, "*.jsonl")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("hermes glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
record := s.parseHermesFile(f)
if record != nil {
allRecords = append(allRecords, *record)
}
}
return mergeRecords(allRecords)
}
// hermesSessionRoot returns the Hermes sessions directory.
func hermesSessionRoot() string {
if hermesHome := os.Getenv("HERMES_HOME"); hermesHome != "" {
dir := filepath.Join(hermesHome, "sessions")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
// Check common locations.
candidates := []string{
filepath.Join(home, ".hermes", "sessions"),
filepath.Join(home, ".local", "share", "hermes", "sessions"),
filepath.Join(home, ".config", "hermes", "sessions"),
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
return ""
}
// hermesLine represents a line in a Hermes session JSONL file.
// Hermes session logs contain both message events and notification events.
type hermesLine struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"` // RFC3339
Model string `json:"model"`
Usage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
} `json:"usage"`
}
// parseHermesFile extracts the final cumulative token usage from a Hermes session file.
// Hermes usage_update events are cumulative snapshots — the last one in the file
// represents the total usage for the session. Returns nil if no usage data found.
func (s *Scanner) parseHermesFile(path string) *Record {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var lastUsage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
}
var lastModel string
var lastTimestamp string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
// Fast pre-filter.
if !bytesContains(line, `"usage"`) && !bytesContains(line, `"inputTokens"`) {
continue
}
var entry hermesLine
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
if entry.Usage == nil {
continue
}
// Take the latest usage snapshot (cumulative).
lastUsage = entry.Usage
if entry.Model != "" {
lastModel = entry.Model
}
if entry.Timestamp != "" {
lastTimestamp = entry.Timestamp
}
}
if lastUsage == nil {
return nil
}
if lastUsage.InputTokens == 0 && lastUsage.OutputTokens == 0 {
return nil
}
// Parse timestamp for date.
var date string
if lastTimestamp != "" {
if ts, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
} else if ts, err := time.Parse(time.RFC3339, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
}
}
if date == "" {
// Fall back to file modification time.
if info, err := os.Stat(path); err == nil {
date = info.ModTime().Local().Format("2006-01-02")
} else {
return nil
}
}
model := lastModel
if model == "" {
model = "unknown"
}
return &Record{
Date: date,
Provider: "hermes",
Model: model,
InputTokens: lastUsage.InputTokens,
OutputTokens: lastUsage.OutputTokens + lastUsage.ThoughtTokens,
CacheReadTokens: lastUsage.CachedReadTokens,
}
}

View File

@@ -0,0 +1,99 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseHermesFile(t *testing.T) {
tmp := t.TempDir()
// Hermes session JSONL with usage_update entries (cumulative snapshots)
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"claude-sonnet-4-5"}
{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":1000,"outputTokens":200,"cachedReadTokens":500,"thoughtTokens":50}}
{"type":"usage_update","timestamp":"2026-04-10T14:02:00.000Z","model":"claude-sonnet-4-5","usage":{"inputTokens":3000,"outputTokens":600,"cachedReadTokens":1500,"thoughtTokens":100}}
`
filePath := filepath.Join(tmp, "session-001.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.Provider != "hermes" {
t.Errorf("provider = %q, want %q", record.Provider, "hermes")
}
if record.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
}
if record.Date != "2026-04-10" {
t.Errorf("date = %q, want %q", record.Date, "2026-04-10")
}
// Should take the last (cumulative) snapshot
if record.InputTokens != 3000 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 3000)
}
// output_tokens + thought_tokens
if record.OutputTokens != 700 {
t.Errorf("output_tokens = %d, want %d (600 + 100)", record.OutputTokens, 700)
}
if record.CacheReadTokens != 1500 {
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 1500)
}
}
func TestParseHermesFile_NoUsage(t *testing.T) {
tmp := t.TempDir()
content := `{"type":"session_start","timestamp":"2026-04-10T14:00:00.000Z","model":"test-model"}
{"type":"message","timestamp":"2026-04-10T14:01:00.000Z","content":"hello"}
`
filePath := filepath.Join(tmp, "session-empty.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record != nil {
t.Errorf("expected nil record for no usage data, got %+v", record)
}
}
func TestParseHermesFile_SingleUsage(t *testing.T) {
tmp := t.TempDir()
content := `{"type":"usage_update","timestamp":"2026-04-10T14:01:00.000Z","model":"hermes-3","usage":{"inputTokens":500,"outputTokens":100,"cachedReadTokens":0,"thoughtTokens":0}}
`
filePath := filepath.Join(tmp, "session-single.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseHermesFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.InputTokens != 500 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 500)
}
if record.OutputTokens != 100 {
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 100)
}
if record.Model != "hermes-3" {
t.Errorf("model = %q, want %q", record.Model, "hermes-3")
}
}

View File

@@ -0,0 +1,154 @@
package usage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
)
// scanOpenClaw reads OpenClaw JSONL session files from
// ~/.openclaw/agents/*/sessions/*.jsonl
// and extracts token usage from assistant message entries.
func (s *Scanner) scanOpenClaw() []Record {
root := openClawSessionRoot()
if root == "" {
return nil
}
// Glob for session files: agents/*/sessions/*.jsonl
pattern := filepath.Join(root, "*", "sessions", "*.jsonl")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("openclaw glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
records := s.parseOpenClawFile(f)
allRecords = append(allRecords, records...)
}
return mergeRecords(allRecords)
}
// openClawSessionRoot returns the OpenClaw agents directory.
func openClawSessionRoot() string {
if openclawHome := os.Getenv("OPENCLAW_HOME"); openclawHome != "" {
dir := filepath.Join(openclawHome, "agents")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir := filepath.Join(home, ".openclaw", "agents")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
return ""
}
// openClawLine represents a line in an OpenClaw JSONL session file.
type openClawLine struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"` // RFC3339
Message *struct {
Role string `json:"role"`
Provider string `json:"provider"`
Model string `json:"model"`
Usage *struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
CacheRead int64 `json:"cacheRead"`
CacheWrite int64 `json:"cacheWrite"`
} `json:"usage"`
} `json:"message"`
}
// parseOpenClawFile extracts token usage records from an OpenClaw session JSONL file.
func (s *Scanner) parseOpenClawFile(path string) []Record {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var records []Record
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
// Fast pre-filter: skip lines that don't contain relevant data.
if !bytesContains(line, `"usage"`) {
continue
}
if !bytesContains(line, `"assistant"`) {
continue
}
var entry openClawLine
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
if entry.Type != "message" || entry.Message == nil || entry.Message.Role != "assistant" || entry.Message.Usage == nil {
continue
}
u := entry.Message.Usage
if u.Input == 0 && u.Output == 0 {
continue
}
// Parse timestamp to get date.
ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
if err != nil {
ts, err = time.Parse(time.RFC3339, entry.Timestamp)
if err != nil {
continue
}
}
model := entry.Message.Model
if model == "" {
model = "unknown"
}
// Construct provider string: if the session has a provider, use "openclaw/<provider>"
// for attribution, but the Record.Provider field should be "openclaw".
provider := "openclaw"
_ = entry.Message.Provider // available but not used in provider field
records = append(records, Record{
Date: ts.Local().Format("2006-01-02"),
Provider: provider,
Model: normalizeOpenClawModel(entry.Message.Provider, model),
InputTokens: u.Input,
OutputTokens: u.Output,
CacheReadTokens: u.CacheRead,
CacheWriteTokens: u.CacheWrite,
})
}
return records
}
// normalizeOpenClawModel returns a model identifier. If the provider is known,
// it prefixes the model name for clarity (e.g. "deepseek/deepseek-chat").
func normalizeOpenClawModel(provider, model string) string {
provider = strings.TrimSpace(provider)
model = strings.TrimSpace(model)
if provider != "" && !strings.Contains(model, "/") {
return provider + "/" + model
}
return model
}

View File

@@ -0,0 +1,93 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseOpenClawFile(t *testing.T) {
tmp := t.TempDir()
// Real OpenClaw session JSONL with session header, model_change, and assistant messages
content := `{"type":"session","version":3,"id":"multica-test","timestamp":"2026-04-11T13:53:05.847Z"}
{"type":"model_change","id":"03c18aae","timestamp":"2026-04-11T13:53:05.855Z","provider":"deepseek","modelId":"deepseek-chat"}
{"type":"message","id":"162ce1b7","parentId":"c90ecabe","timestamp":"2026-04-11T13:53:09.986Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll start by getting the issue details."}],"api":"openai-completions","provider":"deepseek","model":"deepseek-chat","usage":{"input":133,"output":81,"cacheRead":16448,"cacheWrite":0,"totalTokens":16662}}}
{"type":"message","id":"3c063300","parentId":"50e4feb6","timestamp":"2026-04-11T13:53:14.750Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me check the workspace."}],"provider":"deepseek","model":"deepseek-chat","usage":{"input":286,"output":94,"cacheRead":16448,"cacheWrite":0}}}
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
`
filePath := filepath.Join(tmp, "session.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
records := s.parseOpenClawFile(filePath)
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
r := records[0]
if r.Provider != "openclaw" {
t.Errorf("provider = %q, want %q", r.Provider, "openclaw")
}
if r.Model != "deepseek/deepseek-chat" {
t.Errorf("model = %q, want %q", r.Model, "deepseek/deepseek-chat")
}
if r.InputTokens != 133 {
t.Errorf("input_tokens = %d, want %d", r.InputTokens, 133)
}
if r.OutputTokens != 81 {
t.Errorf("output_tokens = %d, want %d", r.OutputTokens, 81)
}
if r.CacheReadTokens != 16448 {
t.Errorf("cache_read_tokens = %d, want %d", r.CacheReadTokens, 16448)
}
if r.Date != "2026-04-11" {
t.Errorf("date = %q, want %q", r.Date, "2026-04-11")
}
}
func TestParseOpenClawFile_NoUsage(t *testing.T) {
tmp := t.TempDir()
// Session with no assistant messages containing usage
content := `{"type":"session","version":3,"id":"empty-session","timestamp":"2026-04-11T13:53:05.847Z"}
{"type":"message","id":"user001","timestamp":"2026-04-11T13:54:00.000Z","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}
`
filePath := filepath.Join(tmp, "session.jsonl")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
records := s.parseOpenClawFile(filePath)
if len(records) != 0 {
t.Errorf("expected 0 records, got %d", len(records))
}
}
func TestNormalizeOpenClawModel(t *testing.T) {
tests := []struct {
provider string
model string
want string
}{
{"deepseek", "deepseek-chat", "deepseek/deepseek-chat"},
{"anthropic", "claude-sonnet-4-5", "anthropic/claude-sonnet-4-5"},
{"", "gpt-4o", "gpt-4o"},
{"openai", "openai/gpt-4o", "openai/gpt-4o"}, // already has /
}
for _, tt := range tests {
got := normalizeOpenClawModel(tt.provider, tt.model)
if got != tt.want {
t.Errorf("normalizeOpenClawModel(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
}
}
}

View File

@@ -0,0 +1,122 @@
package usage
import (
"encoding/json"
"os"
"path/filepath"
"time"
)
// scanOpenCode reads OpenCode message JSON files from
// ~/.local/share/opencode/storage/message/ses_*/*.json
// and extracts token usage from assistant messages.
func (s *Scanner) scanOpenCode() []Record {
root := openCodeStorageRoot()
if root == "" {
return nil
}
// Glob for message files: storage/message/ses_*/*.json
pattern := filepath.Join(root, "ses_*", "*.json")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("opencode glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
record := s.parseOpenCodeFile(f)
if record != nil {
allRecords = append(allRecords, *record)
}
}
return mergeRecords(allRecords)
}
// openCodeStorageRoot returns the OpenCode message storage directory.
func openCodeStorageRoot() string {
// Check XDG_DATA_HOME first, then fall back to ~/.local/share
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dataHome = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(dataHome, "opencode", "storage", "message")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
return ""
}
// openCodeMessage represents the subset of an OpenCode message JSON file we need.
type openCodeMessage struct {
Role string `json:"role"`
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
Time *struct {
Created int64 `json:"created"` // unix milliseconds
} `json:"time"`
Tokens *struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
Reasoning int64 `json:"reasoning"`
Cache *struct {
Read int64 `json:"read"`
Write int64 `json:"write"`
} `json:"cache"`
} `json:"tokens"`
}
// parseOpenCodeFile reads a single OpenCode message JSON file and returns a Record
// if it contains assistant token usage. Returns nil otherwise.
func (s *Scanner) parseOpenCodeFile(path string) *Record {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var msg openCodeMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil
}
// Only count assistant messages with token usage.
if msg.Role != "assistant" || msg.Tokens == nil || msg.Time == nil {
return nil
}
// Skip messages with no meaningful token usage.
if msg.Tokens.Input == 0 && msg.Tokens.Output == 0 {
return nil
}
ts := time.UnixMilli(msg.Time.Created)
date := ts.Local().Format("2006-01-02")
model := msg.ModelID
if model == "" {
model = "unknown"
}
var cacheRead, cacheWrite int64
if msg.Tokens.Cache != nil {
cacheRead = msg.Tokens.Cache.Read
cacheWrite = msg.Tokens.Cache.Write
}
return &Record{
Date: date,
Provider: "opencode",
Model: model,
InputTokens: msg.Tokens.Input,
OutputTokens: msg.Tokens.Output + msg.Tokens.Reasoning,
CacheReadTokens: cacheRead,
CacheWriteTokens: cacheWrite,
}
}

View File

@@ -0,0 +1,141 @@
package usage
import (
"log/slog"
"os"
"path/filepath"
"testing"
)
func TestParseOpenCodeFile(t *testing.T) {
tmp := t.TempDir()
// Real OpenCode message JSON format with token usage
content := `{
"id": "msg_test001",
"sessionID": "ses_test001",
"role": "assistant",
"time": {"created": 1768332037518, "completed": 1768332039410},
"modelID": "claude-sonnet-4-5",
"providerID": "anthropic",
"tokens": {"input": 10916, "output": 5, "reasoning": 100, "cache": {"read": 448, "write": 50}}
}`
filePath := filepath.Join(tmp, "msg_test001.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.Provider != "opencode" {
t.Errorf("provider = %q, want %q", record.Provider, "opencode")
}
if record.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want %q", record.Model, "claude-sonnet-4-5")
}
if record.InputTokens != 10916 {
t.Errorf("input_tokens = %d, want %d", record.InputTokens, 10916)
}
// output_tokens + reasoning
if record.OutputTokens != 105 {
t.Errorf("output_tokens = %d, want %d", record.OutputTokens, 105)
}
if record.CacheReadTokens != 448 {
t.Errorf("cache_read_tokens = %d, want %d", record.CacheReadTokens, 448)
}
if record.CacheWriteTokens != 50 {
t.Errorf("cache_write_tokens = %d, want %d", record.CacheWriteTokens, 50)
}
}
func TestParseOpenCodeFile_UserMessage(t *testing.T) {
tmp := t.TempDir()
// User messages should be ignored (no token usage to report)
content := `{
"id": "msg_user001",
"sessionID": "ses_test001",
"role": "user",
"time": {"created": 1768332037000}
}`
filePath := filepath.Join(tmp, "msg_user001.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record != nil {
t.Errorf("expected nil record for user message, got %+v", record)
}
}
func TestParseOpenCodeFile_NoCache(t *testing.T) {
tmp := t.TempDir()
// Message without cache field
content := `{
"id": "msg_test002",
"sessionID": "ses_test002",
"role": "assistant",
"time": {"created": 1768332037518},
"modelID": "gpt-4o",
"providerID": "openai",
"tokens": {"input": 500, "output": 200, "reasoning": 0}
}`
filePath := filepath.Join(tmp, "msg_test002.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record == nil {
t.Fatal("expected non-nil record")
}
if record.CacheReadTokens != 0 {
t.Errorf("cache_read_tokens = %d, want 0", record.CacheReadTokens)
}
if record.CacheWriteTokens != 0 {
t.Errorf("cache_write_tokens = %d, want 0", record.CacheWriteTokens)
}
if record.Model != "gpt-4o" {
t.Errorf("model = %q, want %q", record.Model, "gpt-4o")
}
}
func TestParseOpenCodeFile_ZeroTokens(t *testing.T) {
tmp := t.TempDir()
// Message with zero tokens should return nil
content := `{
"id": "msg_test003",
"sessionID": "ses_test003",
"role": "assistant",
"time": {"created": 1768332037518},
"modelID": "test-model",
"tokens": {"input": 0, "output": 0, "reasoning": 0}
}`
filePath := filepath.Join(tmp, "msg_test003.json")
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s := NewScanner(slog.Default())
record := s.parseOpenCodeFile(filePath)
if record != nil {
t.Errorf("expected nil record for zero tokens, got %+v", record)
}
}

View File

@@ -25,8 +25,9 @@ func NewScanner(logger *slog.Logger) *Scanner {
return &Scanner{logger: logger}
}
// Scan reads local JSONL log files for both Claude Code and Codex CLI,
// and returns aggregated usage records keyed by (date, provider, model).
// Scan reads local log files for all supported agent runtimes (Claude Code,
// Codex, OpenCode, OpenClaw, Hermes) and returns aggregated usage records
// keyed by (date, provider, model).
func (s *Scanner) Scan() []Record {
var records []Record
@@ -36,6 +37,15 @@ func (s *Scanner) Scan() []Record {
codexRecords := s.scanCodex()
records = append(records, codexRecords...)
openCodeRecords := s.scanOpenCode()
records = append(records, openCodeRecords...)
openClawRecords := s.scanOpenClaw()
records = append(records, openClawRecords...)
hermesRecords := s.scanHermes()
records = append(records, hermesRecords...)
return records
}

View File

@@ -241,6 +241,7 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
}
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
slog.Error("failed to send verification code", "email", email, "error", err)
writeError(w, http.StatusInternalServerError, "failed to send verification code")
return
}
@@ -302,6 +303,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
return
}
// Set HttpOnly auth cookie (browser clients) + CSRF cookie.
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
// Set CloudFront signed cookies for CDN access.
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) {
@@ -411,7 +417,12 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
}
// Fetch user info from Google.
userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
slog.Error("failed to create userinfo request", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
userInfoResp, err := http.DefaultClient.Do(userInfoReq)
@@ -479,6 +490,10 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
http.SetCookie(w, cookie)
@@ -492,6 +507,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
auth.ClearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {

View File

@@ -11,7 +11,6 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
@@ -19,6 +18,7 @@ import (
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/storage"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type txStarter interface {
@@ -41,11 +41,11 @@ type Handler struct {
EmailService *service.EmailService
PingStore *PingStore
UpdateStore *UpdateStore
Storage *storage.S3Storage
Storage storage.Storage
CFSigner *auth.CloudFrontSigner
}
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, s3 *storage.S3Storage, cfSigner *auth.CloudFrontSigner) *Handler {
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
var executor dbExecutor
if candidate, ok := txStarter.(dbExecutor); ok {
executor = candidate
@@ -61,7 +61,7 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
EmailService: emailService,
PingStore: NewPingStore(),
UpdateStore: NewUpdateStore(),
Storage: s3,
Storage: store,
CFSigner: cfSigner,
}
}
@@ -77,14 +77,14 @@ func writeError(w http.ResponseWriter, status int, msg string) {
}
// Thin wrappers around util functions (preserve existing handler code unchanged).
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func parseUUID(s string) pgtype.UUID { return util.ParseUUID(s) }
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
func textToPtr(t pgtype.Text) *string { return util.TextToPtr(t) }
func ptrToText(s *string) pgtype.Text { return util.PtrToText(s) }
func strToText(s string) pgtype.Text { return util.StrToText(s) }
func timestampToString(t pgtype.Timestamptz) string { return util.TimestampToString(t) }
func timestampToPtr(t pgtype.Timestamptz) *string { return util.TimestampToPtr(t) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
func uuidToPtr(u pgtype.UUID) *string { return util.UUIDToPtr(u) }
// publish sends a domain event through the event bus.
func (h *Handler) publish(eventType, workspaceID, actorType, actorID string, payload any) {

View File

@@ -1252,6 +1252,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
AssigneeID: prevIssue.AssigneeID,
DueDate: prevIssue.DueDate,
ParentIssueID: prevIssue.ParentIssueID,
ProjectID: prevIssue.ProjectID,
}
if req.Updates.Title != nil {
@@ -1295,6 +1296,50 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
if _, ok := rawUpdates["parent_issue_id"]; ok {
if req.Updates.ParentIssueID != nil {
newParentID := parseUUID(*req.Updates.ParentIssueID)
// Cannot set self as parent.
if uuidToString(newParentID) == issueID {
continue
}
// Validate parent exists in the same workspace.
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
ID: newParentID,
WorkspaceID: prevIssue.WorkspaceID,
}); err != nil {
continue
}
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
cycleDetected := false
cursor := newParentID
for depth := 0; depth < 10; depth++ {
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
if err != nil || !ancestor.ParentIssueID.Valid {
break
}
if uuidToString(ancestor.ParentIssueID) == issueID {
cycleDetected = true
break
}
cursor = ancestor.ParentIssueID
}
if cycleDetected {
continue
}
params.ParentIssueID = newParentID
} else {
params.ParentIssueID = pgtype.UUID{Valid: false}
}
}
if _, ok := rawUpdates["project_id"]; ok {
if req.Updates.ProjectID != nil {
params.ProjectID = parseUUID(*req.Updates.ProjectID)
} else {
params.ProjectID = pgtype.UUID{Valid: false}
}
}
// Enforce agent visibility for batch assignment.
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {
if ok, _ := h.canAssignAgent(r.Context(), r, *req.Updates.AssigneeID, workspaceID); !ok {
@@ -1372,11 +1417,16 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
if err := h.Queries.DeleteIssue(r.Context(), parseUUID(issueID)); err != nil {
// Collect attachment URLs before CASCADE delete to clean up S3 objects.
attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID)
if err := h.Queries.DeleteIssue(r.Context(), issue.ID); err != nil {
slog.Warn("batch delete issue failed", "issue_id", issueID, "error", err)
continue
}
h.deleteS3Objects(r.Context(), attachmentURLs)
actorType, actorID := h.resolveActor(r, userID, workspaceID)
h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": issueID})
deleted++

View File

@@ -135,16 +135,17 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) {
return
}
limit := int32(90)
if l := r.URL.Query().Get("days"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 365 {
limit = int32(parsed)
days := 90
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
since := pgtype.Date{Time: time.Now().AddDate(0, 0, -days), Valid: true}
rows, err := h.Queries.ListRuntimeUsage(r.Context(), db.ListRuntimeUsageParams{
RuntimeID: parseUUID(runtimeID),
Limit: limit,
Since: since,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list usage")

View File

@@ -646,6 +646,7 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
// Skills can be at different paths depending on the repo structure:
// skills/{name}/SKILL.md (most common)
// .claude/skills/{name}/SKILL.md (Claude Code native discovery)
// plugin/skills/{name}/SKILL.md (e.g. microsoft repos)
// {name}/SKILL.md (skill at repo root level)
defaultBranch := fetchGitHubDefaultBranch(httpClient, owner, repo)
@@ -654,6 +655,7 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
candidatePaths := []string{
"skills/" + skillName,
".claude/skills/" + skillName,
"plugin/skills/" + skillName,
skillName,
}

View File

@@ -15,22 +15,26 @@ import (
func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) }
// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header.
// Auth middleware validates JWT tokens or Personal Access Tokens.
// Token sources (in priority order):
// 1. Authorization: Bearer <token> header (PAT or JWT)
// 2. multica_auth HttpOnly cookie (JWT) — requires valid CSRF token for state-changing requests
//
// Sets X-User-ID and X-User-Email headers on the request for downstream handlers.
func Auth(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("auth: missing authorization header", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
tokenString, fromCookie := extractToken(r)
if tokenString == "" {
slog.Debug("auth: no token found", "path", r.URL.Path)
http.Error(w, `{"error":"missing authorization"}`, http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
slog.Debug("auth: invalid format", "path", r.URL.Path)
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
// Cookie-based auth requires CSRF validation for state-changing methods.
if fromCookie && !auth.ValidateCSRF(r) {
slog.Debug("auth: CSRF validation failed", "path", r.URL.Path)
http.Error(w, `{"error":"CSRF validation failed"}`, http.StatusForbidden)
return
}
@@ -92,3 +96,20 @@ func Auth(queries *db.Queries) func(http.Handler) http.Handler {
})
}
}
// extractToken returns the bearer token and whether it came from a cookie.
// Priority: Authorization header > multica_auth cookie.
func extractToken(r *http.Request) (token string, fromCookie bool) {
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString != authHeader {
return tokenString, false
}
}
if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
return cookie.Value, true
}
return "", false
}

View File

@@ -41,7 +41,7 @@ func TestAuth_MissingHeader(t *testing.T) {
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" {
if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}
@@ -59,7 +59,8 @@ func TestAuth_NoBearerPrefix(t *testing.T) {
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" {
// Non-Bearer Authorization header with no cookie falls through to "missing authorization".
if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" {
t.Fatalf("unexpected body: %s", body)
}
}

View File

@@ -0,0 +1,20 @@
package middleware
import "net/http"
const cspHeader = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' https: data:; " +
"connect-src 'self' wss:; " +
"frame-ancestors 'none'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
func ContentSecurityPolicy(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", cspHeader)
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,36 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestContentSecurityPolicy(t *testing.T) {
handler := ContentSecurityPolicy(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" {
t.Fatal("Content-Security-Policy header is missing")
}
required := []string{
"script-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}
for _, directive := range required {
if !strings.Contains(csp, directive) {
t.Errorf("CSP missing directive %q; got: %s", directive, csp)
}
}
}

View File

@@ -4,8 +4,10 @@ import (
"context"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
@@ -23,11 +25,61 @@ type PATResolver interface {
ResolveToken(ctx context.Context, token string) (userID string, ok bool)
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// TODO: Restrict origins in production
var allowedWSOrigins atomic.Value // holds []string
func init() {
allowedWSOrigins.Store(loadAllowedOrigins())
}
func loadAllowedOrigins() []string {
raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS"))
if raw == "" {
raw = strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
}
if raw == "" {
raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN"))
}
if raw == "" {
return []string{
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
}
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin != "" {
origins = append(origins, origin)
}
}
return origins
}
// SetAllowedOrigins overrides the WebSocket origin whitelist (called from router setup).
func SetAllowedOrigins(origins []string) {
allowedWSOrigins.Store(origins)
}
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
},
}
origins := allowedWSOrigins.Load().([]string)
for _, allowed := range origins {
if origin == allowed {
return true
}
}
slog.Warn("ws: rejected origin", "origin", origin)
return false
}
var upgrader = websocket.Upgrader{
CheckOrigin: checkOrigin,
}
// Client represents a single WebSocket connection with identity.
@@ -220,13 +272,23 @@ func (h *Hub) Broadcast(message []byte) {
h.broadcast <- message
}
// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT or PAT auth.
// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT, PAT, or cookie auth.
func HandleWebSocket(hub *Hub, mc MembershipChecker, pr PATResolver, w http.ResponseWriter, r *http.Request) {
tokenStr := r.URL.Query().Get("token")
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
http.Error(w, `{"error":"workspace_id required"}`, http.StatusUnauthorized)
return
}
if tokenStr == "" || workspaceID == "" {
http.Error(w, `{"error":"token and workspace_id required"}`, http.StatusUnauthorized)
// Resolve token: query param first, then cookie fallback.
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
tokenStr = cookie.Value
}
}
if tokenStr == "" {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}

View File

@@ -1,7 +1,9 @@
package sanitize
import (
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
)
@@ -11,11 +13,6 @@ var httpURL = regexp.MustCompile(`^https?://`)
// policy is a shared bluemonday policy that allows safe Markdown HTML while
// stripping dangerous elements (script, iframe, object, embed, style, on*).
//
// Note: bluemonday operates on raw text, so HTML inside Markdown code blocks
// (e.g. ```<script>```) will also be stripped. This is an acceptable trade-off
// for defense-in-depth — the primary sanitization happens in the frontend via
// rehype-sanitize which understands the Markdown AST.
var policy *bluemonday.Policy
func init() {
@@ -28,8 +25,47 @@ func init() {
policy.AllowAttrs("class").OnElements("code", "div", "span", "pre")
}
// fencedCodeBlock matches ``` or ~~~ fenced code blocks (with optional language tag).
var fencedCodeBlock = regexp.MustCompile("(?m)^(```|~~~)[^\n]*\n[\\s\\S]*?\n(```|~~~)[ \t]*$")
// inlineCode matches backtick-delimited inline code spans.
// Ordered longest-delimiter-first so triple backticks match before doubles/singles.
var inlineCode = regexp.MustCompile("```[^`]+```|``[^`]+``|`[^`]+`")
// HTML sanitizes user-provided HTML/Markdown content, stripping dangerous
// tags (script, iframe, object, embed, etc.) and event-handler attributes.
//
// Code blocks and inline code spans are preserved verbatim so that bluemonday
// does not HTML-escape their contents (e.g. && → &amp;&amp;).
func HTML(input string) string {
return policy.Sanitize(input)
// 1. Extract fenced code blocks, replacing with unique placeholders.
var blocks []string
placeholder := func(i int) string { return fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) }
result := fencedCodeBlock.ReplaceAllStringFunc(input, func(m string) string {
idx := len(blocks)
blocks = append(blocks, m)
return placeholder(idx)
})
// 2. Extract inline code spans.
var inlines []string
inlinePH := func(i int) string { return fmt.Sprintf("\x00INLINE_%d\x00", i) }
result = inlineCode.ReplaceAllStringFunc(result, func(m string) string {
idx := len(inlines)
inlines = append(inlines, m)
return inlinePH(idx)
})
// 3. Sanitize the non-code portions.
result = policy.Sanitize(result)
// 4. Restore inline code spans, then fenced code blocks.
for i, code := range inlines {
result = strings.Replace(result, inlinePH(i), code, 1)
}
for i, block := range blocks {
result = strings.Replace(result, placeholder(i), block, 1)
}
return result
}

View File

@@ -80,6 +80,52 @@ func TestHTML(t *testing.T) {
input: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
want: `<div data-type="fileCard" data-href="http://example.com/file.pdf" data-filename="file.pdf"></div>`,
},
// Code block preservation — entities must NOT be escaped inside code.
{
name: "fenced code block preserves ampersands",
input: "```\na && b\n```",
want: "```\na && b\n```",
},
{
name: "fenced code block preserves angle brackets",
input: "```html\n<div class=\"x\">hello</div>\n```",
want: "```html\n<div class=\"x\">hello</div>\n```",
},
{
name: "inline code preserves ampersands",
input: "run `a && b` in shell",
want: "run `a && b` in shell",
},
{
name: "inline code preserves angle brackets",
input: "use `x < y && y > z`",
want: "use `x < y && y > z`",
},
{
name: "double backtick inline code preserved",
input: "use ``a && b`` here",
want: "use ``a && b`` here",
},
{
name: "script in fenced code block preserved",
input: "```\n<script>alert(1)</script>\n```",
want: "```\n<script>alert(1)</script>\n```",
},
{
name: "script outside code block still stripped",
input: "hello <script>alert(1)</script> world",
want: "hello world",
},
{
name: "mixed code and non-code",
input: "text `a && b` more <script>x</script> end",
want: "text `a && b` more end",
},
{
name: "tilde fenced code block preserves content",
input: "~~~\na && b\n~~~",
want: "~~~\na && b\n~~~",
},
}
for _, tt := range tests {

View File

@@ -0,0 +1,114 @@
package storage
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
)
type LocalStorage struct {
uploadDir string
baseURL string
}
// NewLocalStorageFromEnv creates a LocalStorage from environment variables.
// Returns nil if upload directory cannot be created.
//
// Environment variables:
// - LOCAL_UPLOAD_DIR (default: "./data/uploads")
// - LOCAL_UPLOAD_BASE_URL (optional, e.g., "http://localhost:8080")
func NewLocalStorageFromEnv() *LocalStorage {
uploadDir := os.Getenv("LOCAL_UPLOAD_DIR")
if uploadDir == "" {
uploadDir = "./data/uploads"
}
if err := os.MkdirAll(uploadDir, 0755); err != nil {
slog.Error("failed to create upload directory", "dir", uploadDir, "error", err)
return nil
}
baseURL := strings.TrimSuffix(os.Getenv("LOCAL_UPLOAD_BASE_URL"), "/")
slog.Info("local storage initialized", "dir", uploadDir, "baseURL", baseURL)
return &LocalStorage{
uploadDir: uploadDir,
baseURL: baseURL,
}
}
func (s *LocalStorage) KeyFromURL(rawURL string) string {
if s.baseURL != "" && strings.HasPrefix(rawURL, s.baseURL) {
rawURL = strings.TrimPrefix(rawURL, s.baseURL)
}
prefix := "/uploads/"
if strings.HasPrefix(rawURL, prefix) {
filename := strings.TrimPrefix(rawURL, prefix)
if i := strings.LastIndex(filename, "/"); i >= 0 {
return filename[i+1:]
}
return filename
}
if i := strings.LastIndex(rawURL, "/"); i >= 0 {
return rawURL[i+1:]
}
return rawURL
}
func (s *LocalStorage) Delete(ctx context.Context, key string) {
if key == "" {
return
}
filePath := filepath.Join(s.uploadDir, key)
if err := os.Remove(filePath); err != nil {
if !os.IsNotExist(err) {
slog.Error("local storage Delete failed", "key", key, "error", err)
}
}
}
func (s *LocalStorage) DeleteKeys(ctx context.Context, keys []string) {
for _, key := range keys {
s.Delete(ctx, key)
}
}
func (s *LocalStorage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
dest := filepath.Join(s.uploadDir, key)
if err := os.WriteFile(dest, data, 0644); err != nil {
return "", fmt.Errorf("local storage WriteFile: %w", err)
}
if s.baseURL != "" {
return fmt.Sprintf("%s/uploads/%s", s.baseURL, key), nil
}
return fmt.Sprintf("/uploads/%s", key), nil
}
func (s *LocalStorage) GetFilePath(key string) string {
return filepath.Join(s.uploadDir, key)
}
func (s *LocalStorage) ServeFile(w http.ResponseWriter, r *http.Request, filename string) {
filePath := filepath.Join(s.uploadDir, filename)
slog.Info("serving file", "filename", filename, "filepath", filePath)
// Use http.ServeFile which has built-in path traversal protection
// It sanitizes the path and prevents access outside the directory
http.ServeFile(w, r, filePath)
}
func (s *LocalStorage) UploadFromReader(ctx context.Context, key string, reader io.Reader, contentType string, filename string) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("local storage ReadAll: %w", err)
}
return s.Upload(ctx, key, data, contentType, filename)
}

View File

@@ -0,0 +1,214 @@
package storage
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestLocalStorage_Upload(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
os.Unsetenv("LOCAL_UPLOAD_BASE_URL")
// No LOCAL_UPLOAD_BASE_URL set - should return relative path
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
expectedLink := "/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Upload_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
contentType := "text/plain"
filename := "test.txt"
link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
// When LOCAL_UPLOAD_BASE_URL is set, should return full URL
expectedLink := "http://localhost:8080/uploads/test-key.txt"
if link != expectedLink {
t.Errorf("link = %q, want %q", link, expectedLink)
}
filePath := filepath.Join(tmpDir, "test-key.txt")
stored, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(stored) != string(data) {
t.Errorf("stored data = %q, want %q", stored, data)
}
}
func TestLocalStorage_Delete(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
_, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt")
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
filePath := filepath.Join(tmpDir, "delete-me.txt")
if _, err := os.ReadFile(filePath); err != nil {
t.Fatalf("file should exist: %v", err)
}
store.Delete(ctx, "delete-me.txt")
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file should be deleted")
}
}
func TestLocalStorage_KeyFromURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
// No baseURL set
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"local URL format", "/uploads/abc123.png", "abc123.png"},
{"local URL with subdir", "/uploads/2024/01/image.jpg", "image.jpg"},
{"just filename", "abc123.png", "abc123.png"},
{"full path", "/some/path/to/file.pdf", "file.pdf"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080")
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
tests := []struct {
name string
rawURL string
expected string
}{
{"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"},
{"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "image.jpg"},
{"local URL format still works", "/uploads/abc123.png", "abc123.png"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := store.KeyFromURL(tc.rawURL)
if got != tc.expected {
t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected)
}
})
}
}
func TestLocalStorage_DeleteKeys(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
ctx := context.Background()
data := []byte("hello world")
keys := []string{"file1.txt", "file2.txt", "file3.txt"}
for _, key := range keys {
_, err := store.Upload(ctx, key, data, "text/plain", key)
if err != nil {
t.Fatalf("Upload %s failed: %v", key, err)
}
}
store.DeleteKeys(ctx, keys)
for _, key := range keys {
filePath := filepath.Join(tmpDir, key)
if _, err := os.ReadFile(filePath); !os.IsNotExist(err) {
t.Errorf("file %s should be deleted", key)
}
}
}
func TestLocalStorage_KeyFromURL_Empty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("LOCAL_UPLOAD_DIR", tmpDir)
store := NewLocalStorageFromEnv()
if store == nil {
t.Fatal("NewLocalStorageFromEnv returned nil")
}
if got := store.KeyFromURL(""); got != "" {
t.Errorf("KeyFromURL(\"\") = %q, want empty string", got)
}
}

View File

@@ -32,7 +32,7 @@ type S3Storage struct {
func NewS3StorageFromEnv() *S3Storage {
bucket := os.Getenv("S3_BUCKET")
if bucket == "" {
slog.Info("S3_BUCKET not set, file upload disabled")
slog.Info("S3_BUCKET not set, cloud upload disabled")
return nil
}
@@ -88,21 +88,6 @@ func (s *S3Storage) storageClass() types.StorageClass {
return types.StorageClassIntelligentTiering
}
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// KeyFromURL extracts the S3 object key from a CDN or bucket URL.
// e.g. "https://multica-static.copilothub.ai/abc123.png" → "abc123.png"
func (s *S3Storage) KeyFromURL(rawURL string) string {
@@ -150,16 +135,6 @@ func (s *S3Storage) DeleteKeys(ctx context.Context, keys []string) {
}
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}
func (s *S3Storage) Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error) {
safe := sanitizeFilename(filename)
disposition := "attachment"

View File

@@ -0,0 +1,12 @@
package storage
import (
"context"
)
type Storage interface {
Upload(ctx context.Context, key string, data []byte, contentType string, filename string) (string, error)
Delete(ctx context.Context, key string)
DeleteKeys(ctx context.Context, keys []string)
KeyFromURL(rawURL string) string
}

View File

@@ -0,0 +1,30 @@
package storage
import (
"strings"
)
// sanitizeFilename removes characters that could cause header injection in Content-Disposition.
func sanitizeFilename(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
// Strip control chars, newlines, null bytes, quotes, semicolons, backslashes
if r < 0x20 || r == 0x7f || r == '"' || r == ';' || r == '\\' || r == '\x00' {
b.WriteRune('_')
} else {
b.WriteRune(r)
}
}
return b.String()
}
// isInlineContentType returns true for media types that browsers should
// display inline (images, video, audio, PDF). Everything else triggers a
// download via Content-Disposition: attachment.
func isInlineContentType(ct string) bool {
return strings.HasPrefix(ct, "image/") ||
strings.HasPrefix(ct, "video/") ||
strings.HasPrefix(ct, "audio/") ||
ct == "application/pdf"
}

View File

@@ -149,7 +149,7 @@ func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOpti
"profile": nil,
"cwd": opts.Cwd,
"approvalPolicy": nil,
"sandbox": "workspace-write",
"sandbox": nil,
"config": nil,
"baseInstructions": nil,
"developerInstructions": nilIfEmpty(opts.SystemPrompt),

View File

@@ -95,17 +95,17 @@ func (q *Queries) GetRuntimeUsageSummary(ctx context.Context, runtimeID pgtype.U
const listRuntimeUsage = `-- name: ListRuntimeUsage :many
SELECT id, runtime_id, date, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at FROM runtime_usage
WHERE runtime_id = $1
AND date >= $2
ORDER BY date DESC
LIMIT $2
`
type ListRuntimeUsageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Limit int32 `json:"limit"`
Since pgtype.Date `json:"since"`
}
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]RuntimeUsage, error) {
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Limit)
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Since)
if err != nil {
return nil, err
}

View File

@@ -12,8 +12,8 @@ DO UPDATE SET
-- name: ListRuntimeUsage :many
SELECT * FROM runtime_usage
WHERE runtime_id = $1
ORDER BY date DESC
LIMIT $2;
AND date >= $2
ORDER BY date DESC;
-- name: GetRuntimeUsageSummary :many
SELECT provider, model,