diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..5baaf86f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,7 +248,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) -**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `/` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9be87e00..9451eae8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -474,7 +474,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) ### Clone a repository as a case -**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. +**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `/`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. @@ -487,7 +487,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) - **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary. - **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `/` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`). -Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint). +Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup). Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`. diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2d097a7b..acebbf61 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4291,7 +4291,7 @@ Object.assign(CodemanApp.prototype, { openCloneCasePathPicker() { const input = document.getElementById('cloneCasePath'); PathPicker.open({ - title: 'Choose the folder to clone into', + title: 'Choose the parent folder for the clone', initialPath: input.value.trim(), directoriesOnly: true, onSelect: (path) => { @@ -4400,7 +4400,10 @@ Object.assign(CodemanApp.prototype, { // The server's suggestion wins over the local guess (it is the same function // the case name is validated against), but never over a name the user typed. const nameInput = document.getElementById('cloneCaseName'); - if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName; + if (nameInput && !this._cloneNameEdited && parse.suggestedName) { + nameInput.value = parse.suggestedName; + this.updateCloneCasePathPreview(); + } const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`; if (data.gitAvailable === false) { @@ -4455,7 +4458,7 @@ Object.assign(CodemanApp.prototype, { const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked; if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) { - this.showToast('Choose the folder to clone into', 'error'); + this.showToast('Choose the parent folder for the clone', 'error'); return; } diff --git a/test/clone-custom-folder-ui.test.ts b/test/clone-custom-folder-ui.test.ts index b763490f..c3c0e495 100644 --- a/test/clone-custom-folder-ui.test.ts +++ b/test/clone-custom-folder-ui.test.ts @@ -2,7 +2,10 @@ * @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` / * `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries * `/` exactly as Create New does, and without the checkbox no `path` is sent - * (the server then clones to `~/codeman-cases/`). Runs against the real index.html markup. + * (the server then clones to `~/codeman-cases/`). The "Will clone into" line has to name the + * folder the request will really use, so it follows every way the name or the folder can change: + * typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real + * index.html markup. * * Port: none. */ @@ -33,7 +36,8 @@ function bootClone() { const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value); set('cloneRepoUrl', 'https://github.com/opticon454/bifrost'); set('cloneCaseName', 'bifrost'); - return { app, doc, set, fetchMock }; + const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent; + return { app, win, doc, set, preview, fetchMock }; } async function requestBody(fetchMock: ReturnType) { @@ -72,4 +76,80 @@ describe('Clone Repo custom folder', () => { const { body } = await requestBody(fetchMock); expect(body.path).toBeUndefined(); }); + + it.each([ + ['/mnt/projects', '/mnt/projects/bifrost'], + ['/mnt/projects///', '/mnt/projects/bifrost'], + [' /mnt/projects/ ', '/mnt/projects/bifrost'], + ['/', '/bifrost'], + ['~', '~/bifrost'], + ['~/', '~/bifrost'], + ])('joins the parent %j to the case name', (parent, target) => { + const { app, set } = bootClone(); + set('cloneCasePath', parent); + expect(app._cloneTargetPath()).toBe(target); + }); + + it('follows the name the preflight settles on, which is the one the request will carry', async () => { + const { app, doc, set, preview, fetchMock } = bootClone(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + set('cloneCasePath', '/mnt/projects'); + // The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`. + set('cloneCaseName', 'repo-tab-readme'); + app.updateCloneCasePathPreview(); + expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme'); + app._applyClonePreflight( + { + parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' }, + gitAvailable: true, + }, + 'https://github.com/owner/repo?tab=readme' + ); + expect(preview()).toBe('Will clone into: /mnt/projects/repo'); + await app.cloneCase(); + const { body } = await requestBody(fetchMock); + expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' }); + }); + + it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => { + const { app, win, doc, preview, fetchMock } = bootClone(); + const open = vi.fn(); + (win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open }; + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + app.openCloneCasePathPicker(); + const options = open.mock.calls[0]![0] as { title: string; directoriesOnly: boolean; onSelect(p: string): void }; + expect(options.title).toBe('Choose the parent folder for the clone'); + expect(options.directoriesOnly).toBe(true); + options.onSelect('/mnt/projects'); + expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects'); + expect(preview()).toBe('Will clone into: /mnt/projects/bifrost'); + await app.cloneCase(); + const { body } = await requestBody(fetchMock); + expect(body.path).toBe('/mnt/projects/bifrost'); + }); + + it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => { + const { app, doc, fetchMock } = bootClone(); + app.showToast = vi.fn(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + await app.cloneCase(); + expect(app.showToast).toHaveBeenCalledWith('Choose the parent folder for the clone', 'error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('builds no target and sends nothing without a case name', async () => { + const { app, doc, set, preview, fetchMock } = bootClone(); + app.showToast = vi.fn(); + (doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true; + set('cloneCasePath', '/mnt/projects'); + set('cloneCaseName', ''); + app.updateCloneCasePathPreview(); + expect(app._cloneTargetPath()).toBe(''); + expect(preview()).toBe( + 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.' + ); + await app.cloneCase(); + expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error'); + expect(fetchMock).not.toHaveBeenCalled(); + }); });