mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 20:29:21 +02:00
Compare commits
5 Commits
fix/selfho
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0ee819a6 | ||
|
|
65cc376f11 | ||
|
|
24a59098d6 | ||
|
|
0ef48797ae | ||
|
|
eca36fac84 |
@@ -101,6 +101,13 @@ ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
|
||||
@@ -58,9 +58,11 @@ services:
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -464,7 +464,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
||||
{t(($) => $.create_project.repos_heading)}
|
||||
</div>
|
||||
{workspaceRepos.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{workspaceRepos.map((repo) => {
|
||||
const checked = selectedRepos.includes(repo.url);
|
||||
return (
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
||||
{t(($) => $.resources.popover_title)}
|
||||
</div>
|
||||
{workspace?.repos && workspace.repos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{workspace.repos.map((repo) => {
|
||||
const isAttached = attachedUrls.has(repo.url);
|
||||
const isDisabled = isAttached || createResource.isPending;
|
||||
|
||||
@@ -84,8 +84,8 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
if payload.URL == "" {
|
||||
return nil, errors.New("github_repo: url is required")
|
||||
}
|
||||
if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("github_repo: url must be a valid http(s) URL")
|
||||
if !isValidGitRepoURL(payload.URL) {
|
||||
return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL")
|
||||
}
|
||||
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
|
||||
out, err := json.Marshal(payload)
|
||||
@@ -95,6 +95,49 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
|
||||
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
|
||||
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
|
||||
// guarding against pasted garbage like "not-a-url", not enforcing a strict
|
||||
// grammar — because the actual fetch happens client-side via `git clone` and
|
||||
// the user gets a clearer error from git than from us.
|
||||
func isValidGitRepoURL(s string) bool {
|
||||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||||
switch u.Scheme {
|
||||
case "http", "https", "ssh", "git":
|
||||
return true
|
||||
}
|
||||
}
|
||||
// scp-like ssh shorthand: [user@]host:path with a non-empty host and path,
|
||||
// and no spaces. Reject anything that looks like a URL with a scheme
|
||||
// (those should go through url.Parse above).
|
||||
if strings.Contains(s, " ") || strings.Contains(s, "://") {
|
||||
return false
|
||||
}
|
||||
colon := strings.Index(s, ":")
|
||||
if colon <= 0 || colon == len(s)-1 {
|
||||
return false
|
||||
}
|
||||
// In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful
|
||||
// as a user separator before the first ':'. If '@' appears at or after
|
||||
// the colon it is not the user separator — reject as malformed rather
|
||||
// than guess (and avoid a slice-bounds panic from blindly slicing).
|
||||
at := strings.Index(s, "@")
|
||||
if at >= colon {
|
||||
return false
|
||||
}
|
||||
hostStart := 0
|
||||
if at >= 0 {
|
||||
hostStart = at + 1
|
||||
}
|
||||
host := s[hostStart:colon]
|
||||
path := s[colon+1:]
|
||||
if host == "" || path == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loadProjectForResource resolves the project, enforces workspace ownership,
|
||||
// and returns its DB row. Used by all project_resource handlers.
|
||||
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {
|
||||
|
||||
@@ -135,6 +135,102 @@ func TestProjectResourceLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectResourceAcceptsSSHRepoURLs covers GitHub issue #2484: SSH and
|
||||
// scp-like git URLs must be accepted alongside https URLs, because workspace
|
||||
// repos configured with an SSH remote previously got rejected when attached
|
||||
// to a project.
|
||||
func TestProjectResourceAcceptsSSHRepoURLs(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "SSH repo URL acceptance",
|
||||
})
|
||||
testHandler.CreateProject(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var project ProjectResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
|
||||
t.Fatalf("decode CreateProject: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
|
||||
r = withURLParam(r, "id", project.ID)
|
||||
testHandler.DeleteProject(httptest.NewRecorder(), r)
|
||||
}()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"scp-like", "git@github.com:multica-ai/multica.git"},
|
||||
{"ssh-scheme", "ssh://git@github.com/multica-ai/multica.git"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
|
||||
"resource_type": "github_repo",
|
||||
"resource_ref": map[string]any{"url": tc.url},
|
||||
})
|
||||
req = withURLParam(req, "id", project.ID)
|
||||
testHandler.CreateProjectResource(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateProjectResource(%s): expected 201, got %d: %s", tc.url, w.Code, w.Body.String())
|
||||
}
|
||||
var created ProjectResourceResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
var ref struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
|
||||
t.Fatalf("decode resource_ref: %v", err)
|
||||
}
|
||||
if ref.URL != tc.url {
|
||||
t.Errorf("ref.url = %q, want %q", ref.URL, tc.url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidGitRepoURL(t *testing.T) {
|
||||
good := []string{
|
||||
"https://github.com/multica-ai/multica",
|
||||
"https://github.com/multica-ai/multica.git",
|
||||
"http://github.example.com/x/y",
|
||||
"ssh://git@github.com/multica-ai/multica.git",
|
||||
"ssh://git@github.com:22/multica-ai/multica.git",
|
||||
"git@github.com:multica-ai/multica.git",
|
||||
"git@gitlab.example.com:group/sub/repo.git",
|
||||
}
|
||||
bad := []string{
|
||||
"",
|
||||
"not-a-url",
|
||||
"github.com/multica-ai/multica", // no scheme, no scp-style colon
|
||||
"https://", // empty host
|
||||
"git@github.com", // missing :path
|
||||
"git@:foo/bar", // missing host
|
||||
"git@github.com:", // missing path
|
||||
"ftp://example.com/repo", // unsupported scheme
|
||||
"file:///tmp/repo", // unsupported scheme
|
||||
"some random text with spaces",
|
||||
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
|
||||
"foo:bar@baz", // '@' after ':' with no scheme
|
||||
":foo/bar", // leading ':' with no host
|
||||
}
|
||||
for _, s := range good {
|
||||
if !isValidGitRepoURL(s) {
|
||||
t.Errorf("isValidGitRepoURL(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range bad {
|
||||
if isValidGitRepoURL(s) {
|
||||
t.Errorf("isValidGitRepoURL(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProjectAttachesResources(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user