From 338c93c929b7dfdfb6cecaa0e9b0fefeb168436f Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Fri, 11 Sep 2026 18:07:29 +0200 Subject: [PATCH 1/2] feat(projects): add project from a Git URL (#94) POST /api/projects/clone runs `git clone ` into / and registers the result as a local project through registerLocalProject(), which the plain POST /api/projects now shares. The New-project modal gains one row: a URL (and optional branch) that turns "Add project" into a clone into the folder the user browsed to. git-clone.js holds the pure half: - transports are an allowlist (http(s), ssh, git, user@host:path); ext:: runs a command and file:// clones anything readable, so both are refused, and the list also rides as GIT_ALLOW_PROTOCOL; - nothing user-supplied may look like an option: URL and target follow `--`, a branch starting with `-` is refused, the directory name is one path segment joined onto a parent that passed isPathAllowed(); - it fails instead of waiting: stdin closed, GIT_TERMINAL_PROMPT=0, no TTY, CCS_GIT_CLONE_TIMEOUT_MS (10 min) backstop, target removed on failure. test/git-clone.test.js pins the parser and drives the endpoint against a real server whose PATH holds a fake git (argv/env recorded, failure on demand). Verified live: a shallow clone of this repository over https in 8 s, a nonexistent repo refused in 0.4 s with git's own message. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 26 +++++++ git-clone.js | 71 ++++++++++++++++++ package.json | 2 +- public/index.html | 56 ++++++++++++-- server.js | 79 ++++++++++++++++++-- test/git-clone.test.js | 161 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 git-clone.js create mode 100644 test/git-clone.test.js diff --git a/CLAUDE.md b/CLAUDE.md index d7da493..e441041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -635,6 +635,32 @@ the session's inside the runner. `kbRunBadges()` in `public/kanban.html` replace no turn budget, no retry and no session resume. Wiring one into `taskWorker` is a new execution backend, not a dropdown. +### Add project from a Git URL (issue #94) + +`POST /api/projects/clone` runs `git clone ` into `/` and +registers the result through the same `registerLocalProject()` the plain create uses. +The SPA exposes it as one extra row in the New-project modal: the folder the user +browsed to is the PARENT, and a non-empty URL turns "Add project" into a clone. +`git-clone.js` holds the pure half; `test/git-clone.test.js` drives the endpoint +against a real server whose `PATH` holds a fake `git`. + +- **Transports are an allowlist, enforced twice.** `parseCloneUrl()` accepts + `http(s)://`, `ssh://`, `git://` and `user@host:path` — `ext::` executes an arbitrary + command and `file://` / a bare path clone anything this process can read. The same + list rides as `GIT_ALLOW_PROTOCOL` so a redirect or submodule cannot widen it. +- **Nothing user-supplied may look like an option.** URL and target follow `--`; + `--branch ` cannot, so a branch starting with `-` is refused before git sees it. + The directory name is one path segment (`DIR_NAME_RE`), joined onto a parent that + passed `isPathAllowed()` — a registered workdir widens that allowlist, which is why + the gate cannot be skipped here any more than in `POST /api/projects`. +- **It must fail, not wait.** stdin is closed, `GIT_TERMINAL_PROMPT=0`, no TTY: a + credential or host-key question fails at once (measured: a private/nonexistent GitHub + URL answers in ~0.4 s with git's own line). `CCS_GIT_CLONE_TIMEOUT_MS` (10 min) is the + backstop, and a SIGKILLed clone does NOT clean up after itself the way a failed one + does, so the endpoint removes the target on any non-zero exit. +- **Local projects only.** A clone on a remote host would run over SSH; the remote flow + already takes an existing path. + ### Open in VS Code (issue #63) `editor-links.js` builds the links; `POST /api/editor/open` decides which of the two diff --git a/git-clone.js b/git-clone.js new file mode 100644 index 0000000..ef850aa --- /dev/null +++ b/git-clone.js @@ -0,0 +1,71 @@ +// git-clone.js — "Add project from a Git URL" (issue #94): the pure half. +// +// POST /api/projects/clone in server.js takes a URL and a parent directory the +// user has already browsed to, runs `git clone` into /, and registers +// the result as a local project. Everything that can be decided without touching +// the filesystem or spawning git lives here so it is testable in isolation. +// +// Three rules, each of which exists because the URL and the names are user input +// that end up in a process argv and in a path we create: +// +// - TRANSPORTS ARE AN ALLOWLIST. `ext::` runs an arbitrary command, `file://` and a +// bare local path clone any repository this process can read — neither is a URL a +// project should be created from. The same list is exported as GIT_ALLOW_PROTOCOL +// so a redirect or a submodule cannot widen it after the check. +// - NOTHING WE PASS MAY LOOK LIKE AN OPTION. The URL and the target follow `--`, but +// `--branch ` cannot, so a branch starting with `-` is refused outright rather +// than reaching git as a second flag. +// - THE DIRECTORY NAME IS A SINGLE PATH SEGMENT. Derived from the URL or given by +// the user, it is joined onto a parent that passed isPathAllowed(); `..`, `/` or a +// leading `.` would let the clone land somewhere else. + +const ALLOWED_PROTOCOLS = ['http', 'https', 'ssh', 'git']; + +// A directory we will create — one segment, no leading dot, no separators. +const DIR_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +// git's own rules are looser (check-ref-format), but a branch outside this set is +// far more likely a typo or an injection attempt than a real ref name. +const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +const URL_RE = /^(?:https?|ssh|git):\/\/[^\s/@]+(?:@[^\s/@]+)?(?::\d+)?\/[^\s]+$/; +// scp-like: git@github.com:user/repo.git — no scheme, exactly one ':' after the host. +const SCP_RE = /^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s:]+$/; + +/** + * @param {unknown} raw + * @returns {{ url: string, repoName: string } | null} null when the URL is not one + * we clone from; repoName is the last path segment minus `.git`. + */ +function parseCloneUrl(raw) { + if (typeof raw !== 'string') return null; + const url = raw.trim(); + if (!url || url.startsWith('-') || /[\s\0]/.test(url)) return null; + if (!URL_RE.test(url) && !SCP_RE.test(url)) return null; + const tail = url.replace(/\/+$/, '').split(/[/:]/).pop().replace(/\.git$/i, ''); + if (!DIR_NAME_RE.test(tail)) return null; + return { url, repoName: tail }; +} + +function isValidDirName(s) { + return typeof s === 'string' && DIR_NAME_RE.test(s); +} + +function isValidBranch(s) { + return typeof s === 'string' && BRANCH_RE.test(s) && !s.includes('..') && !s.endsWith('/'); +} + +/** argv for `git`, after the binary. */ +function cloneArgs({ url, target, branch = '', shallow = false }) { + const a = ['clone']; + if (branch) a.push('--branch', branch); + if (shallow) a.push('--depth', '1'); + a.push('--', url, target); + return a; +} + +/** Environment for the clone: never prompt, never widen the transport list. */ +function cloneEnv(base = process.env) { + return { ...base, GIT_TERMINAL_PROMPT: '0', GIT_ALLOW_PROTOCOL: ALLOWED_PROTOCOLS.join(':') }; +} + +module.exports = { ALLOWED_PROTOCOLS, parseCloneUrl, isValidDirName, isValidBranch, cloneArgs, cloneEnv }; diff --git a/package.json b/package.json index a21fc4a..6fa04d1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js", + "test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/task-workers.test.js && node test/git-clone.test.js", "postinstall": "node scripts/install-hooks.js", "release": "node scripts/release.js", "electron:dev": "electron .", diff --git a/public/index.html b/public/index.html index 151b1ad..3f82dee 100644 --- a/public/index.html +++ b/public/index.html @@ -2135,6 +2135,8 @@ .dir-footer input[type=checkbox] { width: 14px; height: 14px; accent-color: var(--accent); cursor: pointer; } /* ─── Project name input in dir modal ─── */ +.dir-clone-row { padding: 8px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 6px; } +.dir-clone-label { font-size: 12px; color: var(--muted); white-space: nowrap; } .proj-name-row { padding: 8px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 8px; } .proj-name-row input { flex: 1; background: var(--s2); border: 1px solid var(--border); color: var(--text); padding: 5px 9px; border-radius: 6px; font-size: 12px; font-family: inherit; outline: none; } @@ -3905,6 +3907,13 @@

New project

Loading...
+ +
+ Clone: + + +
@@ -3940,7 +3949,7 @@

New project

- + @@ -4905,7 +4914,7 @@

Delegate to Agent

'mcp.import.preview.btn':'👁 Перегляд', 'mcp.import.do':'Імпортувати', 'dir.name.label':'Назва:', - 'dir.git.init':'git init (якщо ще не репозиторій)', + 'dir.git.init':'git init (якщо ще не репозиторій)','dir.clone.label':'Клонувати:','dir.clone.url.ph':'Git URL — клонується у вибрану теку (необов’язково)','dir.clone.branch.ph':'гілка','dir.clone.busy':'Клонування…','toast.clone_err':'git clone: {err}','toast.cloned':'✓ Клоновано: {dir}', 'confirm.stop.desc':'Виконання агента буде перервано.', 'rl.type.five_hour':'5-годинний','rl.type.seven_day':'7-денний', 'proj.generating':'Генерація у фоні...', @@ -5308,7 +5317,7 @@

Delegate to Agent

'mcp.import.preview.btn':'👁 Preview', 'mcp.import.do':'Import', 'dir.name.label':'Name:', - 'dir.git.init':'git init (if not already a repository)', + 'dir.git.init':'git init (if not already a repository)','dir.clone.label':'Clone:','dir.clone.url.ph':'Git URL — cloned into the selected folder (optional)','dir.clone.branch.ph':'branch','dir.clone.busy':'Cloning…','toast.clone_err':'git clone: {err}','toast.cloned':'✓ Cloned: {dir}', 'confirm.stop.desc':'Agent execution will be interrupted.', 'rl.type.five_hour':'5-hour','rl.type.seven_day':'7-day', 'proj.generating':'Generating in background...', @@ -5711,7 +5720,7 @@

Delegate to Agent

'mcp.import.preview.btn':'👁 Предпросмотр', 'mcp.import.do':'Импортировать', 'dir.name.label':'Название:', - 'dir.git.init':'git init (если ещё не репозиторий)', + 'dir.git.init':'git init (если ещё не репозиторий)','dir.clone.label':'Клонировать:','dir.clone.url.ph':'Git URL — клонируется в выбранную папку (необязательно)','dir.clone.branch.ph':'ветка','dir.clone.busy':'Клонирование…','toast.clone_err':'git clone: {err}','toast.cloned':'✓ Клонировано: {dir}', 'confirm.stop.desc':'Выполнение агента будет прервано.', 'rl.type.five_hour':'5-часовой','rl.type.seven_day':'7-дневный', 'proj.generating':'Генерация в фоне...', @@ -6065,7 +6074,7 @@

Delegate to Agent

"mcp.export.title":"Exporter tous les MCP vers un fichier JSON","mcp.replace.title":"Remplacera l'existant","mcp.type.stdio":"stdio (commande)","mcp.type.sse":"SSE (flux URL)", "mcp.import.modal.title":"Importer des serveurs MCP","mcp.import.drop":"Glissez un fichier .json ou cliquez pour parcourir","mcp.import.or":"— ou collez le JSON manuellement —", "mcp.import.found":"Serveurs trouvés :","mcp.import.replace":"Remplacer les serveurs personnalisés existants (sinon — fusion)","mcp.import.preview.btn":"👁 Aperçu","mcp.import.do":"Importer", - "dir.name.label":"Nom :","dir.git.init":"git init (si ce n'est pas déjà un dépôt)","confirm.stop.desc":"L'exécution de l'agent sera interrompue.","rl.type.five_hour":"5 heures", + "dir.name.label":"Nom :","dir.git.init":"git init (si ce n'est pas déjà un dépôt)","dir.clone.label":"Cloner :","dir.clone.url.ph":"URL Git — cloné dans le dossier sélectionné (optionnel)","dir.clone.branch.ph":"branche","dir.clone.busy":"Clonage…","toast.clone_err":"git clone : {err}","toast.cloned":"✓ Cloné : {dir}","confirm.stop.desc":"L'exécution de l'agent sera interrompue.","rl.type.five_hour":"5 heures", "rl.type.seven_day":"7 jours","proj.generating":"Génération en arrière-plan...","mcp.parse.no_servers":"Impossible de trouver des serveurs MCP. Format attendu : {\"mcpServers\":{...}} ou compatible.", "mcp.parse.empty":"La liste des serveurs est vide","mcp.cfg.env_json_err":"Format JSON ENV invalide","agent.planning":"🧠 Planification...","agent.fallback_single":"⚠️ Retour au mode simple", "agent.done":"✅ Tous les agents ont terminé","agent.circular_deps":"⚠️ Dépendance circulaire entre agents","agent.stopped":"⏹ Arrêté","msf.working":"En cours","msf.done":"Terminé","msf.error":"Erreur", @@ -6355,7 +6364,7 @@

Delegate to Agent

"mcp.import.title":"ייבוא MCP מקובץ JSON (פורמט Claude Desktop)","mcp.export.btn":"ייצוא","mcp.export.title":"ייצוא כל שרתי ה-MCP לקובץ JSON","mcp.replace.title":"יחליף את הקיימים", "mcp.type.stdio":"stdio (פקודה)","mcp.type.sse":"SSE (זרם URL)","mcp.import.modal.title":"ייבוא שרתי MCP","mcp.import.drop":"גררו קובץ .json או לחצו לעיון","mcp.import.or":"— או הדביקו JSON ידנית —", "mcp.import.found":"שרתים שנמצאו:","mcp.import.replace":"החלפת שרתים מותאמים אישית קיימים (אחרת — מיזוג)","mcp.import.preview.btn":"👁 תצוגה מקדימה","mcp.import.do":"ייבוא","dir.name.label":"שם:", - "dir.git.init":"git init (אם עדיין לא ריפוזיטורי)","confirm.stop.desc":"ריצת ה-agent תופרע.","rl.type.five_hour":"5 שעות","rl.type.seven_day":"7 ימים","proj.generating":"יוצר ברקע...", + "dir.git.init":"git init (אם עדיין לא ריפוזיטורי)","dir.clone.label":"שכפול:","dir.clone.url.ph":"כתובת Git — ישוכפל לתיקייה שנבחרה (אופציונלי)","dir.clone.branch.ph":"ענף","dir.clone.busy":"משכפל…","toast.clone_err":"git clone: {err}","toast.cloned":"✓ שוכפל: {dir}","confirm.stop.desc":"ריצת ה-agent תופרע.","rl.type.five_hour":"5 שעות","rl.type.seven_day":"7 ימים","proj.generating":"יוצר ברקע...", "mcp.parse.no_servers":"לא נמצאו שרתי MCP. צפוי {\"mcpServers\":{...}} או פורמט תואם.","mcp.parse.empty":"רשימת השרתים ריקה","mcp.cfg.env_json_err":"פורמט ENV JSON לא תקין", "agent.planning":"🧠 מתכנן...","agent.fallback_single":"⚠️ עובר למצב יחיד","agent.done":"✅ כל ה-agents סיימו","agent.circular_deps":"⚠️ תלות מעגלית בין ה-agents","agent.stopped":"⏹ הופסק","msf.working":"עובד", "msf.done":"הושלם","msf.error":"שגיאה","msf.waiting":"ממתין","msf.thinking":"חושב","msf.tool":"רץ","msf.generating":"יוצר","ta.calls":"קריאות","ta.activity":"פעילות","msf.team":"צוות Agents", @@ -15559,6 +15568,8 @@

Delegate to Agent

onEscape: () => closeDirModal(), }); $i('gitInitChk').checked = false; + $i('cloneUrlInput').value = ''; + $i('cloneBranchInput').value = ''; $i('projNameInput').value = ''; $i('dirPathInput').value = ''; $i('dirFilterInput').value = ''; @@ -15692,6 +15703,8 @@

Delegate to Agent

} // Local project if (!dirBrowsePath) { toast(t('toast.select_dir'), true); return; } + const cloneUrl = ($i('cloneUrlInput')?.value || '').trim(); + if (cloneUrl) return cloneProjectFromUrl(cloneUrl); const name = ($i('projNameInput').value.trim()) || dirBrowsePath.split(/[/\\]/).filter(Boolean).pop() || dirBrowsePath; const gitInit = $i('gitInitChk').checked; @@ -15711,6 +15724,37 @@

Delegate to Agent

if (_onProjectCreated) { const cb = _onProjectCreated; _onProjectCreated = null; cb(); } } +// Issue #94 — the browsed folder is the parent; the server clones into +// / and registers that as the project. The request stays open for +// the whole clone (server-side timeout), so the button is locked meanwhile. +async function cloneProjectFromUrl(url) { + const branch = ($i('cloneBranchInput')?.value || '').trim(); + const name = $i('projNameInput').value.trim(); + const btn = $i('dirAddBtn'); + const prevLabel = btn.textContent; + btn.disabled = true; + btn.textContent = t('dir.clone.busy'); + try { + const r = await fetch('/api/projects/clone', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, branch, parentDir: dirBrowsePath, name }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok || !d.ok) { toast(t('toast.clone_err').replace('{err}', d.error || r.statusText || '?'), true, 6000); return; } + toast(t('toast.cloned').replace('{dir}', d.workdir)); + await loadProjectsList(); + if (d.id) switchProject(d.id); + closeDirModal(true); + if (_onProjectCreated) { const cb = _onProjectCreated; _onProjectCreated = null; cb(); } + } catch (e) { + toast(t('toast.clone_err').replace('{err}', e.message), true, 6000); + } finally { + btn.disabled = false; + btn.textContent = prevLabel; + } +} + function closeDirModal(preservePending = false) { closeModalOverlay('dirModal'); if (!preservePending) { diff --git a/server.js b/server.js index d748dc3..3a78988 100644 --- a/server.js +++ b/server.js @@ -91,6 +91,7 @@ const auth = require('./auth'); const ClaudeCLI = require('./claude-cli'); // CLAUDE.md / AGENTS.md discovery + the AGENTS.md system-prompt block (issue #54). const agentsMd = require('./agents-md'); +const gitClone = require('./git-clone'); // Read-only remote file browsing (issue #57). Its path guard is POSIX-only on // purpose — see the header of the module. const remoteFiles = require('./remote-files'); @@ -9579,17 +9580,79 @@ app.post('/api/projects', (req,res) => { try { execSync('git init', { cwd:workdir, stdio:'pipe' }); actions.push('git init'); } catch(e) { return res.json({ ok:true, id:null, actions, gitError:(e.stderr?.toString()||e.message).trim() }); } } - const projects = loadProjects(); - const existing = projects.find(p => p.workdir === workdir); - if (existing) { existing.name = name; saveProjects(projects); return res.json({ ok:true, id:existing.id, actions, updated:true }); } - const id = 'proj-' + genId(); - projects.push({ id, name, workdir, createdAt:new Date().toISOString() }); - saveProjects(projects); - if (telegramBot) telegramBot.notifyProjectAdded(workdir, name).catch(() => {}); - res.json({ ok:true, id, actions }); + const { id, updated } = registerLocalProject(name, workdir); + res.json({ ok:true, id, actions, ...(updated ? { updated:true } : {}) }); } catch(e) { res.status(500).json({ error:e.message }); } }); +// Register (or rename) a LOCAL project row. Shared by the plain create above and +// the clone below so the two cannot disagree about what a project record is. +function registerLocalProject(name, workdir) { + const projects = loadProjects(); + const existing = projects.find(p => p.workdir === workdir); + if (existing) { existing.name = name; saveProjects(projects); return { id: existing.id, updated: true }; } + const id = 'proj-' + genId(); + projects.push({ id, name, workdir, createdAt: new Date().toISOString() }); + saveProjects(projects); + if (telegramBot) telegramBot.notifyProjectAdded(workdir, name).catch(() => {}); + return { id, updated: false }; +} + +// ─── Add project from a Git URL (issue #94) ────────────────────────────────── +// Clones into / and registers it as a local +// project. The parent is a directory the user browsed to in the same modal, so it +// goes through the same isPathAllowed() gate as a plain create — a registered +// workdir widens that allowlist, which is why the check cannot be skipped here. +// Validation of the URL, the branch and the directory name lives in git-clone.js. +// Local projects only: a clone on a remote host would need to run over SSH, and +// the remote project flow already takes an existing path. +const GIT_CLONE_TIMEOUT_MS = parseInt(process.env.CCS_GIT_CLONE_TIMEOUT_MS || '600000', 10) || 600000; +app.post('/api/projects/clone', (req, res) => { + const { url, branch = '', parentDir, name = '', dirName = '', shallow = false } = req.body || {}; + const parsed = gitClone.parseCloneUrl(url); + if (!parsed) return res.status(400).json({ error: 'unsupported git url (http(s), ssh, git or user@host:path)' }); + if (branch && !gitClone.isValidBranch(branch)) return res.status(400).json({ error: 'invalid branch name' }); + if (dirName && !gitClone.isValidDirName(dirName)) return res.status(400).json({ error: 'invalid directory name' }); + if (!parentDir || typeof parentDir !== 'string') return res.status(400).json({ error: 'parentDir required' }); + if (!isPathAllowed(parentDir)) return res.status(403).json({ error: 'path not allowed' }); + const parent = path.resolve(parentDir); + let parentIsDir = false; + try { parentIsDir = fs.statSync(parent).isDirectory(); } catch {} + if (!parentIsDir) return res.status(400).json({ error: 'parent directory does not exist' }); + const dir = dirName || parsed.repoName; + const target = path.join(parent, dir); + if (fs.existsSync(target)) return res.status(409).json({ error: `already exists: ${target}` }); + + const args = gitClone.cloneArgs({ url: parsed.url, target, branch, shallow: !!shallow }); + let stderr = ''; + let child; + try { + // stdin is closed, GIT_TERMINAL_PROMPT=0 and no TTY: a credential or host-key + // question fails at once instead of holding the request until the timeout. + child = spawnProc('git', args, { cwd: parent, env: gitClone.cloneEnv(), stdio: ['ignore', 'ignore', 'pipe'] }); + } catch (e) { return res.status(500).json({ error: e.message }); } + child.stderr.on('data', d => { if (stderr.length < 8192) stderr += String(d); }); + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, GIT_CLONE_TIMEOUT_MS); + let settled = false; + const finish = (code, err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err || code !== 0) { + // git removes its own half-clone on failure; a SIGKILL from the timer does not. + try { fs.rmSync(target, { recursive: true, force: true }); } catch {} + const detail = (stderr.trim().split('\n').filter(Boolean).pop() || (err && err.message) || `git exited ${code}`).slice(0, 400); + log.warn('project-clone-failed', { url: parsed.url, target, code, detail }); + return res.status(502).json({ error: detail }); + } + const { id, updated } = registerLocalProject(String(name || '').trim() || dir, target); + log.info('project-cloned', { url: parsed.url, target, id }); + res.json({ ok: true, id, workdir: target, actions: ['git clone'], ...(updated ? { updated: true } : {}) }); + }; + child.on('error', e => finish(null, e)); + child.on('exit', code => finish(code, null)); +}); + app.post('/api/projects/reorder', (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'no ids' }); diff --git a/test/git-clone.test.js b/test/git-clone.test.js new file mode 100644 index 0000000..4f2abbc --- /dev/null +++ b/test/git-clone.test.js @@ -0,0 +1,161 @@ +// "Add project from a Git URL" — issue #94. +// +// Two halves. git-clone.js is pure and is pinned directly: which URLs are accepted, +// what the argv looks like, and that nothing user-supplied can reach git as an +// option. Then POST /api/projects/clone is driven against a REAL server whose PATH +// holds a FAKE `git` — it records its argv and environment, creates the directory +// the way a clone would, and fails on demand — so the suite proves the endpoint's +// gates, the project registration and the failure cleanup without touching the +// network or a developer's real repositories. +// +// Run: node test/git-clone.test.js (TEST_PORT= to move off the default port) +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const G = require('../git-clone'); + +let pass = 0, fail = 0; +function check(label, actual, expected) { + try { assert.deepStrictEqual(actual, expected); pass++; console.log(` ok ${label}`); } + catch { fail++; console.error(` FAIL ${label} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +console.log('\n— git-clone.js: which URLs are cloned from —'); +check('https URL', G.parseCloneUrl('https://github.com/Lexus2016/claude-code-studio.git'), { url: 'https://github.com/Lexus2016/claude-code-studio.git', repoName: 'claude-code-studio' }); +check('scp-like ssh', G.parseCloneUrl('git@github.com:Lexus2016/claude-code-studio'), { url: 'git@github.com:Lexus2016/claude-code-studio', repoName: 'claude-code-studio' }); +check('ssh:// with a port', G.parseCloneUrl('ssh://git@host:2222/a/b.git').repoName, 'b'); +check('git://', G.parseCloneUrl('git://host/x/y.git').repoName, 'y'); +check('trailing slash is tolerated', G.parseCloneUrl('http://h/repo/').repoName, 'repo'); +check('surrounding whitespace is trimmed', G.parseCloneUrl(' https://h/a/b ').url, 'https://h/a/b'); +check('ext:: transport is refused — it executes a command', G.parseCloneUrl('ext::sh -c id'), null); +check('file:// is refused', G.parseCloneUrl('file:///etc'), null); +check('a bare local path is refused', G.parseCloneUrl('/tmp/x'), null); +check('an option-shaped string is refused', G.parseCloneUrl('--upload-pack=id'), null); +check('embedded whitespace is refused', G.parseCloneUrl('git@h:a b'), null); +check('a repo name of `..` is refused', G.parseCloneUrl('https://h/x/..'), null); +check('a dot-leading repo name is refused', G.parseCloneUrl('https://h/x/.git'), null); +check('non-string input is refused', G.parseCloneUrl({ toString: () => 'https://h/a/b' }), null); + +console.log('\n— git-clone.js: argv and environment —'); +check('argv puts -- before the URL and target', G.cloneArgs({ url: 'U', target: 'T' }), ['clone', '--', 'U', 'T']); +check('branch and depth precede --', G.cloneArgs({ url: 'U', target: 'T', branch: 'dev', shallow: true }), ['clone', '--branch', 'dev', '--depth', '1', '--', 'U', 'T']); +check('a branch starting with - is not a branch', G.isValidBranch('-x'), false); +check('a branch with .. is not a branch', G.isValidBranch('a..b'), false); +check('feat/x is a branch', G.isValidBranch('feat/x'), true); +check('a dir name with a separator is refused', G.isValidDirName('a/b'), false); +check('a dir name of .. is refused', G.isValidDirName('..'), false); +check('a plain dir name is accepted', G.isValidDirName('my-repo_1.0'), true); +const env = G.cloneEnv({ FOO: '1' }); +check('env keeps the base', env.FOO, '1'); +check('env never prompts', env.GIT_TERMINAL_PROMPT, '0'); +check('env pins the transport allowlist', env.GIT_ALLOW_PROTOCOL, 'http:https:ssh:git'); + +// ── the endpoint against a real server with a fake git ────────────────────── +const PORT = Number(process.env.TEST_PORT || 4541); +const BASE = `http://127.0.0.1:${PORT}`; +const APP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-clone-app-')); +const HOME_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-clone-home-')); +const BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-clone-bin-')); +const OUTSIDE = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-clone-outside-')); +process.on('exit', () => { for (const d of [APP_DIR, HOME_DIR, BIN_DIR, OUTSIDE]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } }); +fs.mkdirSync(path.join(APP_DIR, 'data'), { recursive: true }); +const WORKSPACE = path.join(APP_DIR, 'workspace'); +fs.mkdirSync(WORKSPACE, { recursive: true }); +const GIT_LOG = path.join(APP_DIR, 'git-calls.log'); + +// Fake git: one line of JSON per call — argv, cwd, the two env vars we care about. +// Creates the target like a clone would. A URL containing "boom" fails AFTER creating +// the directory, which is what a SIGKILLed real clone leaves behind. +fs.writeFileSync(path.join(BIN_DIR, 'git'), `#!/bin/sh +PATH=/usr/bin:/bin:$PATH +printf '%s\\n' "$(node -e 'console.log(JSON.stringify({argv:process.argv.slice(1),cwd:process.cwd(),prompt:process.env.GIT_TERMINAL_PROMPT,allow:process.env.GIT_ALLOW_PROTOCOL}))' -- "$@")" >> "${GIT_LOG}" +for last; do :; done +mkdir -p "$last/.git" +case "$*" in *boom*) echo "fatal: repository 'boom' not found" >&2; exit 128;; esac +exit 0 +`, { mode: 0o755 }); +// `node` must resolve for the fake git, so PATH is the fake dir plus node's own dir. +const PATH_ENV = BIN_DIR + path.delimiter + path.dirname(process.execPath); + +let srvLog = ''; +const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { + env: { ...process.env, PORT: String(PORT), CCS_DESKTOP: '1', APP_DIR, WORKDIR: WORKSPACE, HOME: HOME_DIR, PATH: PATH_ENV }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let exited = false; +child.on('exit', () => { exited = true; }); +child.stdout.on('data', d => { srvLog += d; }); +child.stderr.on('data', d => { srvLog += d; }); +let cleanedUp = false; +function cleanup() { if (cleanedUp) return; cleanedUp = true; if (!exited) { try { child.kill('SIGTERM'); } catch {} } } +process.on('exit', cleanup); +for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { cleanup(); process.exit(1); }); +function die(msg) { console.error(msg); if (srvLog) console.error(srvLog.slice(-2000)); cleanup(); process.exit(1); } + +async function api(method, url, body) { + const res = await fetch(BASE + url, { method, headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined }); + const text = await res.text(); + let json = null; try { json = JSON.parse(text); } catch {} + return { status: res.status, json, text }; +} +const clone = body => api('POST', '/api/projects/clone', body); +const gitCalls = () => fs.existsSync(GIT_LOG) ? fs.readFileSync(GIT_LOG, 'utf8').trim().split('\n').map(l => JSON.parse(l)) : []; + +(async () => { + let up = false; + for (let i = 0; i < 80 && !exited; i++) { + try { const r = await fetch(BASE + '/api/health'); if (r.ok) { up = true; break; } } catch {} + await sleep(250); + } + if (exited) die(`server exited before it became ready — port ${PORT} collision or startup crash`); + if (!up) die(`server on port ${PORT} did not start`); + + console.log('\n— gates, in the order the endpoint applies them —'); + check('ext:: URL is 400 and git is never run', (await clone({ url: 'ext::sh -c id', parentDir: WORKSPACE })).status, 400); + check('option-shaped branch is 400', (await clone({ url: 'https://h/a/b', parentDir: WORKSPACE, branch: '-x' })).status, 400); + check('dirName with a separator is 400', (await clone({ url: 'https://h/a/b', parentDir: WORKSPACE, dirName: '../x' })).status, 400); + check('a parent outside the allowed roots is 403', (await clone({ url: 'https://h/a/b', parentDir: OUTSIDE })).status, 403); + check('a parent that does not exist is 400', (await clone({ url: 'https://h/a/b', parentDir: path.join(WORKSPACE, 'nope') })).status, 400); + fs.mkdirSync(path.join(WORKSPACE, 'taken')); + check('an existing target is 409', (await clone({ url: 'https://h/a/taken.git', parentDir: WORKSPACE })).status, 409); + check('none of the refusals reached git', gitCalls().length, 0); + + console.log('\n— a clone that succeeds —'); + const ok = await clone({ url: 'https://github.com/acme/widget.git', parentDir: WORKSPACE, branch: 'dev' }); + check('answers 200 ok', [ok.status, ok.json?.ok], [200, true]); + const target = path.join(WORKSPACE, 'widget'); + check('workdir is /', ok.json?.workdir, target); + check('the directory exists', fs.existsSync(path.join(target, '.git')), true); + const call = gitCalls()[0]; + check('git argv: clone --branch dev -- ', call.argv, ['clone', '--branch', 'dev', '--', 'https://github.com/acme/widget.git', target]); + check('git ran in the parent', fs.realpathSync(call.cwd), fs.realpathSync(WORKSPACE)); + check('git was told never to prompt', call.prompt, '0'); + check('git was pinned to the transport allowlist', call.allow, 'http:https:ssh:git'); + const projects = (await api('GET', '/api/projects')).json; + const row = projects.find(p => p.id === ok.json.id); + check('a project row was registered', !!row, true); + check('named after the repository when no name was given', row?.name, 'widget'); + check('pointing at the clone', row?.workdir, target); + check('and it is local', !!row?.isRemote, false); + + console.log('\n— a clone that fails —'); + const bad = await clone({ url: 'https://h/a/boom.git', parentDir: WORKSPACE, name: 'named' }); + check('answers 502', bad.status, 502); + check('with git\'s own last stderr line', bad.json?.error, "fatal: repository 'boom' not found"); + check('the half-made directory is removed', fs.existsSync(path.join(WORKSPACE, 'boom')), false); + check('and no project was registered', (await api('GET', '/api/projects')).json.some(p => p.name === 'named'), false); + + console.log('\n— dirName and name overrides —'); + const named = await clone({ url: 'git@github.com:acme/widget.git', parentDir: WORKSPACE, dirName: 'widget2', name: 'Widget Two', shallow: true }); + check('dirName picks the folder', named.json?.workdir, path.join(WORKSPACE, 'widget2')); + check('shallow adds --depth 1', gitCalls().pop().argv.slice(0, 3), ['clone', '--depth', '1']); + check('name is the project name', (await api('GET', '/api/projects')).json.find(p => p.id === named.json.id)?.name, 'Widget Two'); + + cleanup(); + console.log(`\n${pass} passed, ${fail} failed\n`); + process.exit(fail ? 1 : 0); +})().catch(e => die(String(e && e.stack || e))); From 5a3e1081074201aaed03804f8245139cca7cb5d2 Mon Sep 17 00:00:00 2001 From: Lexus2016 Date: Fri, 11 Sep 2026 18:07:50 +0200 Subject: [PATCH 2/2] docs: count git-clone.test.js in the npm test inventory --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e441041..fecb101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ docker compose up -d docker compose logs -f claude-chat ``` -No linting and no build step configured. `npm test` chains 79 test files under `test/`: 19 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 60 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, the Kanban card's run-settings badges (`kanban-run-badges.test.js` pins the card's model/effort/engine chain against the one `startTask` actually resolves — the two live in different files and the card silently lies when they drift), the board-only `create_task` status (`task-backlog.test.js`), i18n completeness, the config precedence resolver plus its secret masking, usage-limit detection, authentication-failure classification (`auth-errors.test.js`, which pins that the detector runs BEFORE the auto-continue in all three agent loops — a reorder there silently restores #86), the filesystem path guard (including the SVG sandbox header and the symlink rule on the `@`-mention search endpoints) plus the tunnel-blocks-terminal rule, WS session re-subscription, the SSH remote CLI-session import, the live engine pane / interactive-prompt watchdog, the subscription engine's spawn command (`engine-spawn-cmd.test.js` starts a real tmux session with a 40 KB system prompt and asserts the child's argv byte-identical — the tmux command may never carry the prompt inline, see #96), the cross-project global workspace aggregation, the rule that an SSH credential never leaves the server process, the Windows command-quoting oracle, the auth token lifecycle, the multi-agent dependency scheduler (waves, plan sanitising, and the rule that a failure warning must survive dep-context truncation), the SSH stream parser's three guards, the SSH run's termination guarantee (`ssh-termination.test.js` drives `ClaudeSSH.send()` through a fake ssh2 `Client` in `require.cache` and asserts `onDone` fires EXACTLY once on every ending — a missed one hangs the chat forever, a doubled one re-emits stderr) and the recovery contract of "Restart Session" (`session-restart.test.js` boots a real server against a fake `claude` that never exits, then pins that a restart ABORTS that turn and releases the session instead of refusing), the remote non-interactive shell environment (`remote-env.test.js`, which runs the generated prelude through real `bash -lc`: it must parse, print nothing on stdout, and never end on a false test — the caller chains `&& claude …` behind it), the remote CLI-list framing parser, the bot inbox's SQL seam (`bot-inbox.test.js` pins that `from_bot AS "from"` keeps the exact key `planInboxDelivery` reads — rename one without the other and every letter is silently retired as malformed), the one-time config/.env migration onto CCS_CONFIG_PATH and the mid-task clarification delivery contract on the subscription engine (`interrupt-delivery.test.js` — pins that the tmux injection block sits BEFORE the poll loop's completion `break`, that draining does not imply delivery, that a failed paste is re-queued and warns non-terminally, and that the task runner passes the same callbacks the chat path does), the CLAUDE.md / AGENTS.md discovery rules (`agents-md.test.js`, which also pins that AGENTS.md reaches the subprocess as `--append-system-prompt` and never as `--system-prompt`), and the remote file browser's three guard layers (`remote-files.test.js` runs the generated POSIX script through a real `/bin/sh` against a temp tree that contains symlinks OUT of the project; `remote-files-api.test.js` boots a server against a fake remote via `CCS_REMOTE_EXEC_HOOK` and drives `/api/files` the way the SPA does), the editor deep links (`editor-links.test.js` pins the two URI shapes literally — the browser link puts `vscode-remote` in the AUTHORITY and the CLI argument puts it in the SCHEME, and collapsing the two silently breaks one path; `editor-open-api.test.js` boots a real server with `PATH` pointed at an EMPTY directory, which both makes the `opened:'client'` fallback deterministic and guarantees the suite never launches an editor window on a developer's desktop), and the new-chat defaults chain (`chat-defaults.test.js` pins the pure resolver — the built-ins are asserted to be exactly what the SPA hardcoded before #58, and the choice lists to be exactly the toolbar's `data-v` sets and `MODEL_MAP`'s aliases; `chat-defaults-api.test.js` boots a real server in a throwaway `APP_DIR` and pins that a project writes back a SPARSE override object — a five-key snapshot passes every other assertion in that file and still breaks the feature). On the render side, `tables.test.mjs` also pins the ReDoS bound in renderMd step 3.4, `xss.test.mjs` runs 24 adversarial payloads end-to-end, and `forged-tokens.test.mjs` covers the case where user text contains the renderer's own placeholder control bytes, and `pane-font.test.mjs` pins the clamp DIRECTION of `_fitEnginePaneFont` (a wide engine pane may only shrink; a narrow split pane must be allowed to grow). `script-scope.test.mjs` pins which `