mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 06:25:56 +02:00
Compare commits
32 Commits
feat/cli-v
...
agent/j/64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea778796a | ||
|
|
68b101fe01 | ||
|
|
e20c507dcc | ||
|
|
95bfd7dd96 | ||
|
|
3bf7f467a2 | ||
|
|
04238bea22 | ||
|
|
c13d365015 | ||
|
|
47eb6cb612 | ||
|
|
1ee4e0501a | ||
|
|
544b9bc971 | ||
|
|
0c19f0d16f | ||
|
|
d74d7f2b7b | ||
|
|
0c2102b951 | ||
|
|
0c28d3cd08 | ||
|
|
7312b5650c | ||
|
|
c7e0863419 | ||
|
|
d7c83bc285 | ||
|
|
4285549381 | ||
|
|
9ed80120e0 | ||
|
|
ec586ebc25 | ||
|
|
ea8cb18f9e | ||
|
|
d011039c58 | ||
|
|
471d4a6838 | ||
|
|
bd42552854 | ||
|
|
31eeb00b59 | ||
|
|
d32c419b6d | ||
|
|
f31a322978 | ||
|
|
5bae3368d7 | ||
|
|
f100b5b707 | ||
|
|
701399536f | ||
|
|
4ca607f888 | ||
|
|
a35f71f65d |
12
.env.example
12
.env.example
@@ -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
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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.
|
||||
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
2
.gitignore
vendored
@@ -48,3 +48,5 @@ _features/
|
||||
*.dmg
|
||||
*.app
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
|
||||
@@ -11,6 +11,7 @@ builds:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.ShortCommit}}
|
||||
- -X main.date={{.Date}}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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:
|
||||
|
||||
@@ -18,10 +18,9 @@ The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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)**
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
13
e2e/env.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
packages/core/utils.test.ts
Normal file
33
packages/core/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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, () => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
223
packages/views/modals/create-issue.test.tsx
Normal file
223
packages/views/modals/create-issue.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -228,7 +228,7 @@ export function SearchCommand() {
|
||||
setOpen(false);
|
||||
push(href);
|
||||
},
|
||||
[push],
|
||||
[push, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./e2e/env";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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, "-", "")
|
||||
|
||||
152
server/internal/auth/cookie.go
Normal file
152
server/internal/auth/cookie.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
173
server/internal/daemon/usage/hermes.go
Normal file
173
server/internal/daemon/usage/hermes.go
Normal 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,
|
||||
}
|
||||
}
|
||||
99
server/internal/daemon/usage/hermes_test.go
Normal file
99
server/internal/daemon/usage/hermes_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
154
server/internal/daemon/usage/openclaw.go
Normal file
154
server/internal/daemon/usage/openclaw.go
Normal 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
|
||||
}
|
||||
93
server/internal/daemon/usage/openclaw_test.go
Normal file
93
server/internal/daemon/usage/openclaw_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
122
server/internal/daemon/usage/opencode.go
Normal file
122
server/internal/daemon/usage/opencode.go
Normal 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,
|
||||
}
|
||||
}
|
||||
141
server/internal/daemon/usage/opencode_test.go
Normal file
141
server/internal/daemon/usage/opencode_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
20
server/internal/middleware/csp.go
Normal file
20
server/internal/middleware/csp.go
Normal 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)
|
||||
})
|
||||
}
|
||||
36
server/internal/middleware/csp_test.go
Normal file
36
server/internal/middleware/csp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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. && → &&).
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
114
server/internal/storage/local.go
Normal file
114
server/internal/storage/local.go
Normal 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)
|
||||
}
|
||||
214
server/internal/storage/local_test.go
Normal file
214
server/internal/storage/local_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
12
server/internal/storage/storage.go
Normal file
12
server/internal/storage/storage.go
Normal 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
|
||||
}
|
||||
30
server/internal/storage/util.go
Normal file
30
server/internal/storage/util.go
Normal 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"
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user