On a Mac without the developer tools, /usr/bin/git is a placeholder: running
it, even as `git --version`, makes macOS open its "install the command line
developer tools" window. The server asked "is git here?" exactly that way on
the first page load (isGitAvailable() from renderIndexHtml), so a no-admin
install on such a Mac ended with that window one second after the server
started, and it would have come back at every start. Found on a new MacBook
on 2026-11-06: the system log names the Codeman server as the parent of that
git process.
One gate now stands in front of every git the server starts
(src/utils/git-availability.ts). On macOS it resolves git on the PATH itself;
when that is the placeholder, `xcode-select -p` must name a developer folder
that holds a git of its own. xcode-select only prints and never opens a
window. Without the tools, git counts as absent and is never started: every
caller gets the ENOENT it already answers with "git is not installed".
A refusal is asked again after a minute, so installing the tools later needs
no server restart for the git features, and isGitAvailable() no longer
memoizes that one refusal.
Gated: the clone and worktree runners, the Git panel's status and fetch, the
update check and repository status, and Read My Mind's workspace signals.
test/git-spawn-gate.test.ts covers the decision and scans src/ for a git spawn
in a file that does not call the gate, with the reviewed list of sites pinned.
Checked for real on macOS 27. With the developer tools present (Command
Line Tools on one Mac, Xcode on another) the gate resolves /usr/bin/git,
finds the tools and allows git, in 3 to 5 ms. On a MacBook with no
developer tools, a fresh no-admin install with this fix started the server
and loaded the page with git reported as absent, no window opened, and the
system log holds no request to install the tools; the same install without
the fix logged that request one second after the server started.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Classify OpenSSL and LibreSSL's SSL_connect errors as unreachable again (`_` is a
word character, so \bssl\b missed them and the user read the temp directory as the
reason), point a cases-folder refusal at Clone Repo instead of Create New, and move
the mechanism and history out of CLAUDE.md into architecture-invariants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
git's refusal quotes the destination, so a folder named openssl or rustls
classified as HOST_UNREACHABLE and the cleanup emptied it. The cleanup now
tests git's own text, DESTINATION_EXISTS is matched first, and ssl/tls are
matched as words. Also: a test for the Docker refusal on the default branch,
the route JSDoc and constant comment, and the folder copy (empty, or new).
A direct clone whose git-slot wait timed out came back with no spawnError, so
the cleanup emptied a folder git never touched. GitRun now says whether git
started and the cleanup requires it. Cloning into a folder at or inside a
Docker case workspace is refused, as git status, fetch and worktrees do, since
git would run the new repository's hooks and filters on the host.
A direct clone into an empty folder emptied the folder after any failure, which
deleted a concurrent attempt's finished clone when git had written nothing.
Clones into one folder are now mutually exclusive in-process, the cleanup is
skipped when git refused the folder, never ran, or the mount is unresponsive,
and a name linked while the clone ran is a 409 that keeps the folder.
Documents path for POST /api/cases/clone.
The custom folder was treated as a parent, so the repository landed in <folder>/<case name>. The chosen folder is now the clone destination: an empty existing folder is cloned into directly (CloneOptions.intoEmptyDir, no temporary sibling, never removed on failure), a new folder is created, and a folder with files is refused.
classifyGitFailure() matches git's English stderr, but cloneRepository()
spawned git with gitNonInteractiveEnv(), which left the host's locale in
place. Under de_DE.UTF-8 a missing ref came back as FAILED / 422 instead of
REF_NOT_FOUND / 400, and a missing repository as 422 instead of 404; the
clone dialog showed "git failed: Schwerwiegend: …". gitNonInteractiveEnv()
now sets LC_ALL=C and LANG=C, as git-workspace-status.ts already does.
Two tests depended on the locale on their own:
- git-status-routes: the spy runner called git without the production
runner's LC_ALL=C, so "Kein Git-Repository" was not recognised.
- custom-model-run-menu-ui: the modal formats with toLocaleString(), the
test hard-coded 16,384 and 40,000.
Full suite under de_DE.UTF-8: 5 failed before, 0 after.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Addresses the review on #472.
- CRED_STORES: `.config/gh` and `.azure` now carry `enabledByEnv`
(CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ), and resolveDockerCredentialArtifacts
skips a store unless that variable is exactly `1`, read at container
create. A host that merely has ~/.config/gh/hosts.yml or a plaintext MSAL
cache no longer copies them into every case container. Tests: the default
environment seeds neither even with the files present, and each store
follows only its own switch.
- Multi-user mode: a non-admin's Clone Repo clone and preflight run with
`git -c credential.helper=` (GIT_NO_CREDENTIAL_HELPERS, placed before the
subcommand), so the server account's helpers are never lent to them.
Verified against a real private repo that it also clears the URL-scoped
credential.<url>.helper entries, and that public clones still work.
Tests: the argv in test/git-clone.test.ts, and the route decision
(non-admin cleared; admin and single-user kept) in
test/routes/case-clone-credential-helpers.test.ts.
- Docs: recreate the case container to pick up seeds (docker/README.md,
Docker-Cases wiki, docker-cases.md); the multi-user behaviour in
docker/README.md and security-architecture.md; "functionally unchanged"
instead of "unchanged" for an image built with both switches off
(server.Dockerfile comment, README, changeset).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
Add Case -> Clone Repo could only reach public repositories in the Docker
deployment. This lets a deployment opt in to the GitHub CLI and the Azure
CLI (+ azure-devops extension) as git credential helpers. Codeman itself
still collects no credentials.
- server.Dockerfile / agent.Dockerfile: CODEMAN_INSTALL_GH /
CODEMAN_INSTALL_AZ build args (0 or 1, default 0; anything else stops the
build). Off leaves no apt repository, package, extension, helper script
or credential entry, so a default build is unchanged. On installs from
the vendors' apt repositories and configures system gitconfig helpers:
github.com / gist.github.com -> `gh auth git-credential`, dev.azure.com /
*.visualstudio.com -> new docker/git-credential-azure-cli (an Entra ID
token from `az account get-access-token`, or AZURE_DEVOPS_EXT_PAT).
A helper whose CLI is not signed in prints nothing, so a private clone
still fails fast.
- The extension lives in AZURE_EXTENSION_DIR outside HOME
(/opt/codeman-az-extensions, runtime-owned; /opt/az-extensions, gid-0
group-writable in the agent image).
- Hosts turn them on in docker-compose.override.yml: `build: args:` for the
server image, `environment:` CODEMAN_AGENT_IMAGE_INSTALL_GH / _AZ for the
agent image. build-agent-image.mjs and the in-app auto-build share one
env -> ARG table (pinned by the parity test) and pass nothing when unset.
docker-compose.yaml is untouched; .env.example only gains a comment, so
the self-updater's environment gate sees no new keys.
- Docker cases seed the gh sign-in (~/.config/gh/hosts.yml, config.yml) and
the az sign-in files from ~/.azure per file, read-only, like pi/grok.
- The Clone Repo AUTH_REQUIRED message says how to sign the server's git
in instead of claiming private repositories cannot be cloned.
- Docs: docker/README.md "Private repositories", docker-compose.md,
docker-cases.md, the Quick-Start / Core-Concepts / Docker-Cases wiki
pages, security-architecture.md, architecture-invariants.md, changeset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167CiuzLrmjYWxwKp3rMWjw
Post-merge follow-ups for #328 (GET /api/system/repo-status):
- Event-loop blocking: every git invocation in repo-status.ts is now async
(promisified execFile), never execFileSync — the per-remote ls-remote +
fetch could hold the event loop (SSE, PTY streaming) for up to ~60s per
request. The whole computation is single-flight with a 45s TTL cache
(createSingleFlightCache): concurrent requests share one in-flight
promise, a fresh result is served without spawning git, and a rejected
compute is never cached. Route handler shape and response fields
unchanged; remotes still processed sequentially (concurrent fetches in
one repo contend on ref locks).
- Credential disclosure: the redaction from git-clone.ts is extracted as
exported redactGitCredentials() (sanitizeGitOutput now uses it) and
applied via redactRemoteStatus() to every remote card's url and error
string, so a scheme://user:token@host remote URL (or git stderr echoing
it) never reaches a client.
- Non-interactive env: runGit() now uses the shared gitNonInteractiveEnv()
instead of a partial GIT_TERMINAL_PROMPT/BatchMode env, also closing the
GIT_ASKPASS/SSH_ASKPASS/SSH_ASKPASS_REQUIRE/DISPLAY/GCM_INTERACTIVE
prompt paths.
- Upstream parse bug: a local-branch upstream (@{upstream} with no slash,
e.g. after `git branch -u otherbranch`) made slice(0, indexOf('/')) into
slice(0, -1) and yielded garbage like "maste". parseTrackingRemote()
(pure, unit-tested) returns null for it, and the bare ref is dropped so
it cannot be mistaken for a remote-tracking ref downstream.
Tests extended in test/repo-status.test.ts (parseTrackingRemote,
redactGitCredentials/redactRemoteStatus, createSingleFlightCache
single-flight/TTL/rejection semantics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses all four findings from the #251 review:
- Scaffolding no longer writes through repository-controlled symlinks.
The guard lives in hooks-config.ts (settingsWriteBlocker) so it also
covers quick-start/docker/ralph writers, not just the clone route:
refuses a symlinked .claude or settings.local.json, a .claude that is
a file, or one resolving outside the case. The clone route surfaces
the refusal as a user-visible warning, and the CLAUDE.md write checks
presence via lstat so a BROKEN repo-shipped symlink counts as present
(existsSync follows links and would have created the outside target).
- Failed-clone cleanup can no longer delete a concurrent winner's tree:
git clones into an attempt-owned temp sibling (.<name>.cloning-<rand>)
which is atomically renamed into place; the loser reports
DESTINATION_EXISTS and only ever removes its own temp dir.
- decodeURIComponent(url.pathname) is guarded: malformed percent-escapes
now come back as BAD_SYNTAX instead of an uncaught URIError 500.
- The git pool's waiter queue is bounded (CODEMAN_MAX_GIT_QUEUE, default
16): overflow answers BUSY immediately (HTTP 429 via RATE_LIMITED),
and queue time counts against the operation's own deadline.
Tests: hostile symlink fixture repo (route level), settingsWriteBlocker
units, concurrent same-destination race, temp-dir leak assertions,
percent-escape rejection, and a fake-git pool-bounds suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an Add Case -> "Clone Repo" tab plus two endpoints, implementing
@DodgyBadger's proposal in #236: clone a public repository straight into
codeman-cases/<name> and register it as a normal local case.
POST /api/cases/clone is synchronous by design (request held open, bounded
by GIT_CLONE_TIMEOUT_MS): no job store, no polling, no cancellation
surface. Success broadcasts the usual case:created event, so the case
still appears when a proxy idle-timeout kills the request mid-clone.
POST /api/cases/clone-preflight runs `git ls-remote --symref` so the UI can
say, while the user is still typing, whether the URL is cloneable without
credentials, what its default branch is, and which branches/tags exist.
Core lives in src/git-clone.ts, split into a pure half (URL parse, argv/env,
ls-remote parse, stderr classification) and a thin IO half, so every
security decision is unit-testable without spawning anything:
- `<name>::<payload>` transports are refused as a family, not by name:
ext:: is the famous one, but any of them dispatches to git-remote-<name>
and turns a clone into arbitrary command execution.
- A leading `-` is refused AND every spawn puts `--` before the operands.
Either alone is one edit away from being a hole.
- argv arrays, never a shell. URLs carrying user:password@ are refused.
- gitNonInteractiveEnv() closes all four ways git can block on a prompt
with no terminal attached (terminal prompt, askpass/GUI, ssh, GCM).
HOME/PATH stay inherited, so a user's own credential helper or ssh agent
keeps working; Codeman itself collects and stores nothing.
- The timeout signals the process GROUP, since clone fans out into
git-remote-https/index-pack children that outlive a signal to the parent.
- Bounded output (redacted stderr tail, capped ls-remote stdout, 500 refs
each) and a global 2-op pool, so N large clones cannot exhaust the host.
Repository contents beat scaffolding: an existing CLAUDE.md is kept, hooks
are merged into whatever .claude/settings.local.json the repo shipped, and
a repo that ships its own Claude settings is reported back as a warning
(those hooks run locally as soon as a session starts there). A failed clone
removes only the directory the attempt created, and refuses a pre-existing
destination outright, so it can never squat on a case name.
Not admin-gated in multi-user mode, unlike /api/cases/link: it writes only
inside the caller's own case space. Local-path/file:// sources are the
exception and stay admin-only there.
UI: live verdict under the URL field, case name filled from the parsed repo
until the user types their own, branch/tag as a datalist of the remote's
real refs, optional shallow clone, and a Brain picker (installed CLIs only)
that points the Run button at the chosen agent. Starting a session stays
opt-in. The tab hides itself when the server reports no git.
Tests: the pure half exhaustively (every refusal has a case), plus real git
against a real local bare repo for clone/ref/timeout/cleanup, and a
route-level suite with unmocked fs that clones through the endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>