Repository navigation
feat(machines): normalize-and-accept branch/project names instead of rejecting - #2016
Conversation
📝 WalkthroughWalkthroughMachine branch and project names now accept free text, normalize server-side into canonical slugs, use those values for lookup and persistence, and return them through API responses. Branch provisioning metadata, validation, project confinement, and related tests were updated. ChangesMachine name normalization
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant MachinesAPI
participant MachineService
participant NameNormalizer
participant Store
Client->>MachinesAPI: Submit free-text machine name
MachinesAPI->>MachineService: Forward raw name
MachineService->>NameNormalizer: Normalize branch or project name
MachineService->>Store: Lookup or persist canonical name
Store-->>MachineService: Canonical record
MachineService-->>MachinesAPI: Normalized result and metadata
MachinesAPI-->>Client: Canonical name in response
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db509d8b80
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (name.endsWith(LOCK_SUFFIX)) { | ||
| name = `${name.slice(0, -LOCK_SUFFIX.length)}-lock`; |
There was a problem hiding this comment.
Rewrite .lock on every branch path segment
When free text produces an internal .lock segment, such as feature/a.lock /foo normalizing to feature/a.lock/foo, this only rewrites a .lock suffix on the whole ref, so the normalized name is later passed to git checkout -b even though Git rejects it. Git's check-ref-format docs state that no slash-separated component may end with .lock (https://git-scm.com/docs/git-check-ref-format), and git check-ref-format --branch feature/a.lock/foo exits fatal, so these inputs still fail with checkout_failed instead of being normalized and accepted.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in b05e949 — thank you, this was a real hole.
You're right that git check-ref-format forbids a .lock ending on every slash-separated component, not just the ref's tail. My rewrite was anchored on the whole name (\.lock$), so feature/a.lock/foo normalized "cleanly", passed the predicate, and then died at git checkout -b with checkout_failed — precisely the failure mode this PR exists to eliminate.
Two changes:
rewriteLockSuffixnow runs per segment in the slugify map, and once more after the length cut (the cut only ever lands in the last surviving segment, and can itself mint a.lockout ofhotfix.lockdowntruncated at 200).FORBIDDEN_SEGMENT_REtightened from\.lock$to\.lock(\/|$), so the predicate now states git's actual rule. This matters beyond the normalizer: the predicate is the contract the invariant is proven against, so a too-permissive predicate made the invariant weaker than it looked.
New pinned cases: a.lock/b → a-lock/b, feature/a.lock /foo → feature/a-lock/foo, plus both added to the gnarly corpus that sweeps validity + idempotency.
Verified against ground truth rather than just our own predicate: I ran every normalized name from the corpus (41 inputs, including x.lock/y.lock/z.lock, a@{b, ../../etc/passwd, 250-char strings) through real git check-ref-format --branch — 41/41 accepted.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/src/app/api/machines/projects/route.ts (1)
127-127: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
removeProjectdoes not normalize the name before lookup — DELETE with free text will 404.The PR objectives state "Server-side services normalize names before … lookup." The graph context for
removeProject(machine-projects.ts:226-250) shows it callsdeps.store.findByName(machineId, name)with the raw, un-normalizednamefrom the DELETE handler. A user who creates a project with"My Cool Feature"(persisted as"my-cool-feature") and then DELETEs using the original free text will get a404 not_found— inconsistent withaddProjectandattachBranch, which both normalize before lookup.Consider normalizing in
removeProject(or in the route before calling it) so DELETE accepts the same free text as POST.Proposed fix: normalize in removeProject
export async function removeProject({ machineId, name, deps, }: { machineId: string; name: string; deps: MachineProjectsDeps; }): Promise<RemoveProjectResult> { - const existing = await deps.store.findByName(machineId, name); + const normalized = normalizeProjectName(name); + const existing = await deps.store.findByName(machineId, normalized); if (!existing) return { ok: false, reason: 'not_found' };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/machines/projects/route.ts` at line 127, Normalize the project name before lookup in removeProject, ensuring its deps.store.findByName call uses the same normalization behavior as addProject and attachBranch. Preserve the existing deletion and not-found handling while allowing DELETE requests to resolve free-text names such as “My Cool Feature”.apps/web/src/app/api/machines/branches/route.ts (1)
139-139: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winNormalize the branch name before
killBranch()lookup. The DELETE path passes rawbranchNamethrough, butkillBranch()looks up the row without normalizing first. A request using the user-entered name instead of the canonical slug will returnnot_found; matchattachBranch()and normalize here too.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/machines/branches/route.ts` at line 139, Normalize the branch name before the DELETE path calls killBranch(), matching the normalization used by attachBranch(). Pass the canonical normalized value in the killBranch lookup instead of raw branchName.value, while preserving the existing not_found behavior for genuinely missing branches.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/lib/src/services/machines/machine-projects.ts`:
- Around line 37-49: The removeProject flow still looks up projects using the
raw caller-supplied name. Normalize the name with normalizeProjectName before
performing the lookup and deletion, so original free-text names resolve to the
same canonical slug used by addProject, duplicate checks, clone paths, and
persistence.
---
Outside diff comments:
In `@apps/web/src/app/api/machines/branches/route.ts`:
- Line 139: Normalize the branch name before the DELETE path calls killBranch(),
matching the normalization used by attachBranch(). Pass the canonical normalized
value in the killBranch lookup instead of raw branchName.value, while preserving
the existing not_found behavior for genuinely missing branches.
In `@apps/web/src/app/api/machines/projects/route.ts`:
- Line 127: Normalize the project name before lookup in removeProject, ensuring
its deps.store.findByName call uses the same normalization behavior as
addProject and attachBranch. Preserve the existing deletion and not-found
handling while allowing DELETE requests to resolve free-text names such as “My
Cool Feature”.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 38edc0ca-05bd-4c23-b58e-ff7f25cf17ac
📒 Files selected for processing (13)
apps/web/src/app/api/machines/branches/__tests__/route.test.tsapps/web/src/app/api/machines/branches/route.tsapps/web/src/app/api/machines/projects/__tests__/route.test.tsapps/web/src/app/api/machines/projects/route.tspackages/lib/src/services/machines/__tests__/branch-session.test.tspackages/lib/src/services/machines/__tests__/machine-branches.test.tspackages/lib/src/services/machines/__tests__/machine-projects.test.tspackages/lib/src/services/machines/__tests__/project-paths.test.tspackages/lib/src/services/machines/branch-session.tspackages/lib/src/services/machines/machine-branches.tspackages/lib/src/services/machines/machine-projects.tspackages/lib/src/services/machines/name-slug.tspackages/lib/src/services/machines/project-paths.ts
d5e2bdc to
bfa07bf
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/lib/src/services/machines/name-slug.ts (1)
126-158: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winConsider widening the digest to reduce collision risk.
FNV-1a/32-bit gives a real (if small) chance that two distinct non-sluggable names collide on the same
slugDigest. Per the file's own docstring, that specific failure mode is severe — it would makespawnBranchhand one user another user's Sprite/filesystem. Widening the hash (e.g., two FNV-1a passes with different seeds, concatenated) would meaningfully shrink that residual risk while staying sync/pure/dependency-free for the browser live-preview use case.♻️ Example approach to widen the digest
+const FNV_OFFSET_BASIS_2 = 0x9dc5; + export function slugDigest(input: string): string { - let hash = FNV_OFFSET_BASIS; const text = nameIdentity(input); - for (let i = 0; i < text.length; i += 1) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, FNV_PRIME); - } - return (hash >>> 0).toString(36); + let h1 = FNV_OFFSET_BASIS; + let h2 = FNV_OFFSET_BASIS ^ FNV_OFFSET_BASIS_2; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + h1 = Math.imul(h1 ^ code, FNV_PRIME); + h2 = Math.imul(h2 ^ code, FNV_PRIME + 2); + } + return `${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/lib/src/services/machines/name-slug.ts` around lines 126 - 158, Widen the digest produced by slugDigest beyond the current single 32-bit FNV-1a pass to materially reduce collisions between distinct non-sluggable names. Keep it synchronous, pure, dependency-free, and based on nameIdentity; use an additional independently seeded FNV-1a pass and combine the unsigned base36 results into the returned token.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/app/api/machines/projects/route.ts`:
- Around line 84-90: Update the validation condition in the projects route to
require body.repoUrl to be a string with non-empty trimmed content, matching the
existing body.name validation and the error message. Preserve the current 400
response for invalid name or repoUrl values.
---
Nitpick comments:
In `@packages/lib/src/services/machines/name-slug.ts`:
- Around line 126-158: Widen the digest produced by slugDigest beyond the
current single 32-bit FNV-1a pass to materially reduce collisions between
distinct non-sluggable names. Keep it synchronous, pure, dependency-free, and
based on nameIdentity; use an additional independently seeded FNV-1a pass and
combine the unsigned base36 results into the returned token.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f17b6695-01f9-4875-9522-43cc44a827d0
📒 Files selected for processing (14)
apps/web/src/app/api/machines/branches/__tests__/route.test.tsapps/web/src/app/api/machines/branches/route.tsapps/web/src/app/api/machines/projects/__tests__/route.test.tsapps/web/src/app/api/machines/projects/route.tsapps/web/src/hooks/useMachineBranches.tspackages/lib/src/services/machines/__tests__/branch-session.test.tspackages/lib/src/services/machines/__tests__/machine-branches.test.tspackages/lib/src/services/machines/__tests__/machine-projects.test.tspackages/lib/src/services/machines/__tests__/project-paths.test.tspackages/lib/src/services/machines/branch-session.tspackages/lib/src/services/machines/machine-branches.tspackages/lib/src/services/machines/machine-projects.tspackages/lib/src/services/machines/name-slug.tspackages/lib/src/services/machines/project-paths.ts
🚧 Files skipped from review as they are similar to previous changes (9)
- apps/web/src/app/api/machines/projects/tests/route.test.ts
- packages/lib/src/services/machines/tests/project-paths.test.ts
- packages/lib/src/services/machines/tests/machine-projects.test.ts
- apps/web/src/app/api/machines/branches/tests/route.test.ts
- packages/lib/src/services/machines/project-paths.ts
- packages/lib/src/services/machines/tests/machine-branches.test.ts
- packages/lib/src/services/machines/tests/branch-session.test.ts
- apps/web/src/app/api/machines/branches/route.ts
- packages/lib/src/services/machines/machine-branches.ts
…rejecting Typing "My Cool Feature" as a branch or project name used to fail with an error: `isValidBranchName` / `isValidProjectName` were boolean reject gates on the create path. Now free text is slugified into a valid git ref / path segment and accepted — `my-cool-feature`. - `normalizeBranchName` (branch-session.ts) and `normalizeProjectName` (project-paths.ts), colocated with the predicates they must satisfy, over a shared `slugifySegment` primitive (name-slug.ts): NFKD-fold accents, lowercase, replace out-of-charset characters with `-`, collapse separator runs (which also destroys `..`), trim edge separators, enforce the existing max length, and fall back to `branch`/`project` when nothing survives. Branch names keep `/` as structural (git ref namespacing) and normalize each segment independently; a `.lock` suffix — which the length cut can itself mint — is rewritten last. - The `isValid*` predicates are UNCHANGED and remain the contract. Hard invariant, swept over a gnarly-input corpus in the colocated table-driven tests: `isValid*(normalize*(x)) === true` for every input, normalization is idempotent, and every already-valid name is a fixed point. - Wired into the authoritative server-side create path: `spawnBranch` / `attachBranch` (machine-branches.ts) and `planAddProject` / `addProject` (machine-projects.ts) normalize before the name is checked out, hashed into the session key, cloned to disk, or persisted — the raw text never reaches git or the DB. Both POST routes echo the canonical name back rather than the request's, so two spellings of one name land on ONE branch-terminal instead of two. `resolveProjectPath` still re-checks confinement. Client-side live preview is a separate follow-up sub-task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…f's tail git's check-ref-format forbids a `.lock` ending on every slash-separated component, not only on the ref as a whole — so `feature/a.lock/foo` normalized cleanly, passed our predicate, and then died at `git checkout -b` with `checkout_failed`. That is exactly the "type anything, it works" promise this PR exists to make. - `rewriteLockSuffix` now runs per segment (and once more after the length cut, which lands only in the last segment and can itself mint a `.lock`). - `FORBIDDEN_SEGMENT_RE` tightened from `\.lock$` to `\.lock(\/|$)` so the predicate states git's real rule; the normalizer's hard invariant is then a stronger guarantee, not a weaker one. - Table + gnarly corpus extended: `a.lock/b` → `a-lock/b`, `feature/a.lock /foo` → `feature/a-lock/foo`. Verified beyond our own predicate: every normalized name from the corpus is accepted by real `git check-ref-format --branch` (41/41). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ore regression) Slugifying unconditionally was actively harmful, not merely lossy. Git refs are CASE-SENSITIVE: `Release-2.0` normalized to `release-2.0`, so `git checkout -b release-2.0 origin/release-2.0` missed the real upstream branch — and cloneAndCheckoutBranch's existing fallback then SILENTLY created a new empty branch off HEAD while `origin/Release-2.0` sat there untouched. The user asked for their branch and got an empty one, with no error. Uppercase was valid pre-PR, so this was a regression the PR introduced. `feature_x` was worse still: pre-PR a loud 400, post-PR a silent wrong-and-empty `feature-x`. - Both normalizers now return an ALREADY-VALID name untouched. Normalization exists to accept text git would REJECT — never to rewrite text git would have honored. Idempotency falls out of this for free (output is always valid, so a second pass is the identity). - `HEAD` added to the predicate's reject set: it is git's reserved symbolic ref and the only such name in our charset (`head`, `Head`, `FETCH_HEAD` are all legal — verified against git). It now slugifies to `head` instead of being passed through to a fatal `git checkout -b HEAD`. - `killBranch` normalizes its lookup key, like `attachBranch` — whatever text created a branch must also be able to kill it. - Route/service docs no longer claim a client-side live preview exists; it is a separate follow-up sub-task. Fuzzed 20,019 inputs (random unicode, ligatures, fullwidth, surrogates, length boundaries): invariant, idempotency, and PROJECTS_ROOT confinement all hold, and every distinct normalized branch name (737) is accepted by real `git check-ref-format --branch`. 362 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The last name-keyed lookup still doing an exact match on raw caller input. `addProject` persists the CANONICAL name, so deleting by the free text that created the project would 404 — the same asymmetry already fixed in `attachBranch`/`killBranch`. Caught by CodeRabbit. Tests pin the round trip on both tiers: create with "My Cool Feature", delete with "My Cool Feature". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…r too
The last raw name-keyed lookup. `addProject` persists the CANONICAL project
name, so free text that created a project ("My Cool Feature" → my-cool-feature)
could not spawn, attach, kill, or list a branch inside it — `project_not_found`.
Same class as the removeProject fix; this closes the set.
spawnBranch / attachBranch / killBranch / listBranches now normalize projectName
before every store lookup and before the scope key. Canonical names — what the
UI sends, straight from listProjects — pass through unchanged, since
normalization is idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…-attach bug)
A branch name IS a branch-terminal's identity — it is hashed into the Sprite
session key and is the store's lookup key. Every non-Latin name slugified to
NOTHING and fell back to the shared `branch`, so:
spawnBranch('日本語') -> branch (provisions Sprite A)
spawnBranch('한국어') -> branch -> { resumed: true }, hands the user Sprite A
The second user silently got the FIRST user's Sprite and filesystem. Same for
`feature/日本語`, which collapsed to plain `feature` and collided with any real
`feature` branch. This was the default outcome for every non-Latin-script user.
- A segment the ASCII charset annihilates but which DID carry a name now keeps a
deterministic digest token (`slugDigest`, FNV-1a/base36 — a disambiguator, not
a security primitive; the Sprite name remains a keyed HMAC). Structural noise
(`..`, `.`, ` `) still has no content and is still dropped, which is what
keeps `../escape` → `escape`. The task spec explicitly allows "a short suffix"
here. Kept pure/dependency-free so the live-preview sub-task can run it in the
browser.
- `spawnBranch` now reports `createdNew`, surfaced on the API response. Our
charset is deliberately NARROWER than git's (git accepts `_wip`, `fix#123`,
`日本語`; we reject them, because the charset is a confinement boundary), so
normalization can rewrite a name that DOES exist upstream into one that
doesn't — and git's fallback then creates a new empty branch off HEAD. That
outcome is now stated rather than silent.
- Corrected a doc claim that was false as written ("never rewrite text git would
have honored") — it is only true of names our predicate accepts.
Fuzzed 30,024 inputs: invariant, idempotency, and PROJECTS_ROOT confinement hold;
all 748 distinct branch outputs accepted by real `git check-ref-format`; 10/10
distinct non-ASCII names stay distinct. 380 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…lation
The previous fix only disambiguated names the ASCII charset wiped out
ENTIRELY, so the partial case still cross-attached — the same bug, one step
subtler:
'日本語 feature' -> 'feature' (a DIFFERENT branch, likely someone else's)
'🚀 launch' -> 'launch'
'feature/日本語-fix' -> 'feature/fix'
'中文 feature' and '日本語 feature' -> the SAME branch
Now a digest is appended whenever normalization destroys identity-bearing
content — a letter, digit, or symbol from a script the charset cannot express.
Losing ASCII punctuation stays lossless-enough and is still accepted (`a b` and
`a!b` were always one branch; demanding otherwise would make `My Cool Feature`
ugly for no safety gain). Accented Latin folds to ASCII losslessly, so
`émoji branch` keeps its clean slug — only the emoji triggers a digest.
Also fixed in the same class:
- The LENGTH CUT was lossy: two different over-long names cut to the same ref.
The cut now reserves room for a digest of the original.
- `___` is a legal git branch but fell into the shared fallback bucket
(`_` was wrongly classed as structure, not content), so it cross-attached with
every content-free input.
- `createdNew` now reaches the client: useMachineBranches types and returns it.
The docs claimed the create-vs-attach outcome was "stated, not silent" — that
was only true down to the JSON layer, so the claim was false end-to-end.
- POST /api/machines/projects with an EMPTY name returned 201 and cloned into a
directory called `project`. An empty name is a missing field, not free text;
it is a 400 now, matching the branches route.
Fuzzed 30,031 inputs: invariant, idempotency, confinement hold; all 788 distinct
branch outputs accepted by real `git check-ref-format`; 23/23 human-distinct
names stay distinct as both branches and projects. 384 + 35 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ee over-claims
CI caught a real type error: `args.args` is optional on RunCommandArgs
(TS18048) in the createdNew test. Fixed — @pagespace/lib typecheck is green.
Also, from a fourth adversarial pass:
- REAL BUG: untrimmed input defeated the pass-through. `normalizeBranchName(
'Release-2.0 ')` — one trailing space — failed the predicate, fell down the
slug path, and downcased to `release-2.0`: exactly the case-regression the
pass-through exists to prevent. Both normalizers now trim BEFORE the predicate
test. The UI happened to .trim() first, so this only bit API/agent callers —
but the normalizer is the stated authority, not the dialog.
- Three comments over-claimed and are now exactly true:
* `createdNew` is "stated, not silent" — true only down to the JSON layer.
Nothing renders it yet; the comments now say so, and it stays threaded
through the hook so the spawn-flow sub-task can surface it.
* "Normalizes the project key like every other name-keyed lookup" — true only
within machine-branches/machine-projects. The agent-terminal / files / diff
surfaces still take raw names; closing that needs the realtime session-key
path too, so it is a stated follow-up rather than a drive-by.
* `rewriteLockSuffix` no longer runs after the length cut (the appended digest
is alphanumeric, so the cut cannot mint a `.lock`), so the note saying it
does is gone.
- `hasNameContent`'s doc now explains why it is deliberately MORE eager than
`destroysNameContent`: dropping `!` from `a!b` is harmless, but `!!!` slugifies
to nothing, and dropping THAT lands the name in the shared fallback — the one
bucket where distinct names collide and cross-attach.
Re-fuzzed 30,031 inputs after the trim change: invariant, idempotency,
confinement hold; 789 distinct branch outputs all accepted by real git;
23/23 human-distinct names stay distinct. 386 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The digest hashed the RAW input while the slug beside it was built from the
folded, lowercased form. So the moment a name held any character the ASCII
charset cannot express, every "accepted loss" the design promises became
identity-bearing again:
'MY 🚀 FEATURE' -> my-feature-n0kelm } two branches, two Sprites,
'my 🚀 feature' -> my-feature-14q1eru } two clones — for ONE name
'a 🚀 b' / 'a!🚀!b' / 'a 🚀 b' -> three different branches
The direction was safe (it splits, never cross-attaches), but it broke the
"case and ASCII punctuation do not split a name" promise for precisely the
non-ASCII users the digest was introduced to protect: retyping your own branch
name with different capitalisation minted a second branch-terminal.
`slugDigest` now hashes `nameIdentity(input)` — folded, lowercased, ASCII
structure collapsed, non-ASCII characters KEPT. `日本語` stays apart from
`한국어`, and `日本語 feature` from `feature`, while `MY 🚀 FEATURE` and
`my 🚀 feature` are one branch again.
Two adjacent fixes:
- The blank-name guards tested `length === 0`, not `trim().length === 0`, so
`name: " "` sailed through and cloned into a directory called `project`
(branch: `branch`) — exactly what the guard's own comment said it prevented.
Both routes now reject blank names as the missing fields they are.
- `addProject` could `rm -rf` a CONCURRENT winner's checkout: the loser's clone
fails *because* the winner's succeeded ("destination path already exists"),
and the blind cleanup deleted their files while their row lived on, leaving a
project pointing at an empty directory. It now only cleans up a path no
persisted project owns. Pre-existing, but normalization widens the race from
"same text" to "same slug", so it is in scope. The test fails without the fix.
Fuzzed 30,031 inputs: invariant, idempotency, confinement hold; 784 distinct
branch outputs accepted by real git; 23/23 human-distinct names stay distinct;
and the same-name groups (case / punctuation / whitespace, with and without
emoji) each collapse to exactly ONE name. 389 + 35 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
My previous "don't delete the winner's clone" guard was in a place where it could never fire. The winner's row lands only once ITS clone finishes, while the loser's clone fails the instant the directory appears — so the loser looked for a winner's row that did not exist yet, saw none, and `rm -rf`'d the winner's freshly cloned files out from under them, leaving a project row pointing at an empty directory. No amount of checking at cleanup time can fix that ordering. `addProject` now reserves the name in the store BEFORE it clones. The unique constraint on (machineId, name) is what makes the race safe: the loser fails instantly on the constraint, never runs git, and never touches the filesystem. On clone failure the winner rolls back both its own directory and its own row — a path it provably owns. The cost is a row that exists while the clone runs. That is the right trade: a row with no directory is visible and deletable; a directory with no row is an orphan nobody can reach. The test now models the REAL interleaving (directory appears immediately, clone finishes later, row lands last) instead of the one convenient ordering where the old guard happened to fire. Verified honestly: it FAILS against the post-clone guard and PASSES against the reservation — the previous version of this test passed against both, which is exactly the sort of test that lets a bug ship. Also un-exported BRANCH_NAME_FALLBACK / PROJECT_NAME_FALLBACK — nothing imports them. 389 lib + 128 web machine tests pass; fuzz (30,031 inputs) still clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
… window)
Reserving the name before cloning — the previous commit — made the row visible
for the WHOLE clone. That opened a window nobody had before: an impatient user
can delete the project mid-clone (clones take seconds to minutes) and re-add it.
The name is then owned by a NEW row and a NEW checkout — and the original call's
rollback, which cleaned up BY NAME, would `rm -rf` that new checkout and delete
that new row when its own clone finally failed:
A: reserve my-repo, clone starts (slow, will fail)
user: delete my-repo -> row gone, dir gone
C: re-add my-repo -> new row, new clone, succeeds
A: clone fails -> rm -rf my-repo ← destroys C's checkout
-> remove my-repo ← destroys C's row
The rollback now compares row identity first and touches nothing if the
reservation is no longer ours. Verified honestly: the test FAILS without the
identity check and PASSES with it.
Also covered the `!result.success` half of the failure branch (sandbox
unreachable / quota / provision failure), which reserves a row like any other add
and so must roll it back too — previously untested.
And corrected a comment that promised more than it delivers: the digest token
does not make punctuation-only names unique (`!!!` and `###` share an empty
identity, exactly as `a!b` and `a#b` share a slug). What it guarantees is that a
name with real identity never lands in the bucket with names that have none.
391 lib tests pass; fuzz (30,031 inputs) clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…race instead
I tried to fix a pre-existing concurrency bug inside a normalization PR, and each
fix introduced the next one:
1. reserve-after-clone + a guard -> guard could never fire (the winner's row
lands only after ITS clone finishes, so the loser's fast-failing clone sees
no row and rm -rf's the winner's checkout anyway)
2. reserve-BEFORE-clone -> made the row visible for the whole clone, so
a delete-then-re-add mid-clone leaves an ORPHAN DIRECTORY and returns
ok: true. Timing-independent — just an impatient Delete click.
3. + identity-checked rollback -> closed that, but left a TOCTOU where a
stalled rollback still rm -rf's a later owner's checkout.
Three attempts, three new bugs, is the signal. `addProject`'s control flow is now
restored EXACTLY to master's — only the normalized name differs. The race is
documented in place instead, honestly: it is pre-existing, normalization widens
its trigger from "two callers typed the same text" to "two callers typed the same
NAME" (`My Repo` / `my repo`), and it is not fixable by reordering.
The real fix is structural and earns its own PR: make the clone path unique per
ROW (`${name}-${id}`) rather than per name, so no two operations can ever own the
same directory, and make every destructive store op id-scoped. `removeProject`
needs the same treatment — it has the identical by-name TOCTOU on master today.
This keeps the PR to what it is for: name normalization. It ships no new
concurrency semantics and no new bugs.
388 lib + 128 web machine tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
The last hole in the same family. POST and both branches routes reject blank names; `DELETE /api/machines/projects` still used a bare `!name`, so `" "` sailed through — and since `removeProject` normalizes its lookup key, a whitespace-only name resolves to the FALLBACK and deletes a project literally called `project`, `rm -rf`ing its checkout. Proven end-to-end against the real service. A blank name is a missing field, not free text. 400 now, with a regression test. 129 web machine-route tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…llback too
The trim guards were the right idea with the wrong predicate. "Nameless" is
broader than "blank": `.`, `..`, `...`, `//`, `-` all have `trim().length > 0`
yet carry no name, and every one of them normalizes to the FALLBACK. So they
sailed straight through and ADDRESSED A REAL RESOURCE:
DELETE /api/machines/projects?name=.. -> rm -rf's the project named `project`
POST /api/machines/branches {branchName: '.'}
-> attaches to the branch-terminal named
`branch`, handing back its Sprite
Both proven against the real services. Same class as the whitespace bug fixed one
commit ago, one spelling away — which is exactly why the guard now uses the
normalizer's OWN definition of namelessness (`hasNameContent`) instead of a
`.trim()` that only catches spaces. Verified as a property: every input that
would reach a fallback is blocked, and no input carrying a real name is
over-blocked (`!!!`, `___`, `-x`, `日本語`, `🚀` all still pass).
`name-slug` gets a package export so the routes can share that one predicate —
which the client-side live-preview sub-task will need anyway.
Also corrected the comment in project-paths.ts that justified the shared fallback
with "the routes reject blank names outright" — that claim was load-bearing and,
until this commit, false.
388 lib + 141 web machine tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…ectly Two nits from the final review, neither a bug: - `requireString` was guarding `machineId` with a NAME predicate, so `machineId=..` 400'd as "machineId is required". An id is opaque, never normalized, and needs no name predicate — split into `requireId` (non-empty) and `requireName` (hasNameContent). Also renamed the local `Required<T>` helper, which shadowed TypeScript's built-in utility type. - `name-slug.ts` had no direct test; its four exports were covered only transitively. Now unit-tested, including the two properties the API depends on: `hasNameContent` covers exactly the inputs that reach a fallback (it is what the route guards key on), and `slugDigest` hashes a name's IDENTITY — so it separates `日本語` from `한국어` without splitting `MY 🚀 FEATURE` from `my 🚀 feature`. 438 lib + 141 web machine tests pass; typecheck and eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
508f2f5 to
a353b59
Compare
…POST The nameless guard was tested on POST only, yet DELETE is the destructive path — `killBranch` normalizes BOTH keys, so an unguarded `?branchName=..` would resolve to the FALLBACK and delete a real branch-terminal's Sprite, and a nameless `?projectName=` on GET would list another project's branches. The guard was already present on all three methods; now it is pinned there. Also fixed a comment in the projects route that still pointed at `requireString`, which the previous commit renamed to `requireName`. 151 web machine-route tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
CodeRabbit caught that the error message I wrote — "name and repoUrl are required non-empty strings" — was only true of `name`: `repoUrl` was checked for `typeof === 'string'`, so `repoUrl: ""` sailed past it and failed further downstream as `invalid_repo_url` instead. Still a 400, but the guard did not mean what it said. `repoUrl` is now checked for presence here (it is NOT normalized — there is no sensible way to turn a non-HTTPS remote into an HTTPS one — so `isValidRepoUrl` inside `addProject` remains the real validator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
My previous commit hoisted the two checks into aliased booleans and OR'd them — and TS's aliased-condition narrowing does not flow out of that, so `body.name` stayed `unknown` at the `addProject` call (TS2322 x2). Caught by running the apps/web typecheck rather than assuming; CI would have failed on it. Two separate guard statements narrow correctly. Behaviour is identical: blank name or blank repoUrl → 400. apps/web typecheck: 0 errors. 153 web machine-route tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
… a GET case Cosmetic follow-ups from the review of the last three commits: - The two guards repeated the same 400 literal, which is how a message and its guard drift apart. One `missingNameOrRepoUrl()` helper now backs both, and it is a function rather than a hoisted const so the happy path does not build a Response it never returns. - The GET nameless-projectName case list was missing `'.'` (DELETE had it). Free coverage; `requireName` is shared so nothing was actually unguarded. apps/web typecheck 0 errors; 154 web machine-route tests pass; eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
…it twice Behaviour-preserving cleanup, from a simplification pass on the finished diff. The digest-disambiguation rule — the single most safety-critical thing in this PR, since it is what stops `日本語` and `한국어` collapsing onto one branch and handing the second user the first one's Sprite — was implemented TWICE, in `normalizeBranchSegment` and inside `normalizeProjectName`, with two separately maintained WHY comments that had already begun to drift. So was the truncate-with-digest block, and its trailing-separator regex existed in three places. Both now live once in name-slug.ts as `disambiguateSlug` and `truncateWithDigest`, with the reasoning stated once. The two normalizers lose their `let` and their if/else ladders and read as what they are. `attachBranch` / `killBranch` / `removeProject` now shadow their raw params with the canonical values the way `spawnBranch` already did, so "every name-typed identifier in these modules is the canonical one" holds by inspection rather than by memory. Proved behaviour-preserving rather than assumed: the new implementation is BYTE-IDENTICAL to the old one across 63,027 inputs (curated adversarial + fuzz, including the length and `.lock` boundaries). 438 lib tests pass — including the pinned digest expectations, which would move on any behavioural drift — and the property fuzz still holds (invariant, idempotency, confinement, real `git check-ref-format`, no-collision). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
Sub-task 1 of the Terminal UX redesign (Terminal→Machine GA epic), node
c6lmwa9at0372ac0icaq9ncc. Independent of the tree/pane rewrite and the Development surface — touches no UI.The problem
Branch and project name handling validated and rejected.
isValidBranchName(branch-session.ts) andisValidProjectName(project-paths.ts) are boolean gates, so a user who typedMy Cool Featuregot an error and had to guess the charset a git ref allows.The change: normalize and accept
Type anything; it becomes a valid name.
My Cool Featuremy-cool-featuremy-cool-featurefeat/JIRA-123 Fix!!feat/jira-123-fixfeat-jira-123-fix../escapeescapeescapea.lock/ba-lock/ba.lock-bHEADheadHEADRelease-2.0Release-2.0(untouched)Release-2.0日本語xnbamha(distinct, never shared)xnbamhaTwo pure functions —
normalizeBranchName,normalizeProjectName— colocated with the predicates they must satisfy, over a sharedslugifySegmentprimitive (name-slug.ts). Three rules do the real work, and each exists because of a bug found in review:1. A name the predicate already accepts is returned untouched. Normalization exists to accept text that would be rejected, never to rewrite text that already worked.
2. When the ASCII charset destroys identity-bearing content, a digest keeps the name distinct. Only structural noise (
..,.,) is dropped — which is what still collapses../escapetoescape.3. The digest hashes the name's identity (folded, lowercased, ASCII-structure collapsed), not its raw text — so
日本語stays apart from한국어without also splittingMY 🚀 FEATUREfrommy 🚀 feature.Six real bugs caught in review — every one a silent wrong answer
.lockon inner segments (Codex). Git forbids a.lockending on every slash-separated component, not just the ref's tail, sofeature/a.lock/foonormalized "cleanly", passed the predicate, then died atgit checkout -b. Now rewritten per segment;FORBIDDEN_SEGMENT_REtightened to\.lock(\/|$)to state git's real rule.Case/underscore regression. Git refs are case-sensitive. Slugifying
Release-2.0→release-2.0madegit checkout -b release-2.0 origin/release-2.0miss the real upstream branch, and the existing fallback then silently created a new empty branch off HEAD whileorigin/Release-2.0sat untouched. Uppercase was valid pre-PR, so this PR would have introduced it. → Rule 1.Non-ASCII names collapsed onto one branch — cross-attach. A branch name is a branch-terminal's identity (hashed into the Sprite session key, used as the store's lookup key). Every non-Latin name slugified to nothing and fell back to a shared
branch, sospawnBranch('日本語')thenspawnBranch('한국어')returnedresumed: trueand handed the second user the first user's Sprite and filesystem. The default outcome for every non-Latin-script user. → Rule 2.Partial annihilation.
日本語 feature→feature— a different branch that may already exist and belong to someone else. Digesting only total annihilation wasn't enough. → Rule 2, generalized.The digest split names it was meant to protect. It hashed raw text while the slug beside it was folded, so
MY 🚀 FEATUREandmy 🚀 featureminted two branches, two Sprites, two clones — for one name. → Rule 3.Nameless names addressed real resources. Every nameless string normalizes to a fallback (
branch/project) — and a fallback is a real name someone can own. SoDELETE /api/machines/projects?name=..rm -rf'd the project calledproject, andPOST /api/machines/branches {branchName: "."}attached the caller to the branch-terminal calledbranch, handing back its Sprite. This family produced four separate bugs (blank → whitespace →./..///→ the DELETE path), because each fix used a narrower predicate than the normalizer itself. The guards now key on the normalizer's ownhasNameContent, verified as a two-way property: every input that reaches a fallback is rejected, and no real name (!!!,___,日本語,🚀) is over-blocked.Also:
HEADis git's reserved symbolic ref and the only such name in our charset (head,Head,FETCH_HEADare all legal — verified against git); the length cut was itself lossy (two over-long names cut to one ref, so the cut now reserves room for a digest); and untrimmed input ("Release-2.0 ") skipped the pass-through and downcased.A deliberately accepted limitation, stated rather than silent
Our charset is narrower than git's. Git accepts
_wip,fix#123,v1.0+build,日本語; we don't, because the charset is a confinement boundary (the name lands ingit checkout -bargv, a store key, and a scope key). So those names are still rewritten — and if one names an existing upstream branch, git's fallback creates a new empty branch instead.spawnBranchtherefore returnscreatedNew(surfaced on the API response and threaded through the hook): did we check out an existing upstream branch, or create a brand-new one? Nothing renders it yet — that belongs with the spawn-flow sub-task — but the outcome is now knowable rather than invisible. Widening the charset to git's full rule is a separate, security-reviewable change.The invariant
The
isValid*predicates remain the contract:isValid*(normalize*(x)) === truefor every inputPROJECTS_ROOTServer-side wiring (authoritative)
Normalization lives in the services, not the routes, so it cannot be bypassed. Every name-keyed path normalizes —
spawnBranch,attachBranch,killBranch,listBranches(bothprojectNameandbranchName),addProject,removeProject— so whatever text created a thing can also find, attach to, and delete it. Canonical names re-fetched from the list APIs pass through unchanged, because normalization is idempotent.Both POST routes echo the service's canonical name rather than the request's. The existing hooks already re-fetch and render the server's record, so no client change is needed.
planSpawnBranchand theinvalid_branch_namedenial are deleted (feature is behindCODE_EXECUTION_ENABLED, no compat shim).invalid_namesurvives inplanAddProjectonly as the unreachable second confinement gate.Validation
㍿, combining marks, lone surrogates, CJK, emoji, both truncation boundaries): invariant, idempotency, and confinement all hold.git check-ref-format --branch.@pagespace/libandapps/webtypecheck clean; eslint clean.name-slug.tsasdisambiguateSlug/truncateWithDigest). Proved rather than asserted: byte-identical output across 514,760 differential inputs vs the pre-refactor implementation.Known issues NOT fixed here (deliberately)
A pre-existing concurrency race in
addProject. Two concurrent adds of the same project both clone into one path; the loser's clone fails because the winner's succeeded, and the cleanuprm -rfs the winner's checkout. Normalization widens the trigger from "two callers typed the same text" to "two callers typed the same name" (My Repo/my repo), so it's called out in-code.I attempted a fix three times and each attempt introduced a worse bug (reserving the name before the clone makes the row visible mid-clone, so a delete-then-re-add leaves an orphan directory and returns
ok: true).addProject's control flow is therefore restored exactly to master's — only the normalized name differs. The real fix is structural and earns its own PR: make the clone path unique per row (${name}-${id}) so no two operations can own one directory, and make every destructive store op id-scoped.removeProjecthas the identical by-name TOCTOU on master today and needs the same treatment.The sibling agent-terminal / files / diff surfaces don't normalize their name params, so a direct API caller can
POST /api/machines/branches {branchName: "My Cool Feature"}successfully but getbranch_not_foundfromPOST /api/machines/agent-terminalswith the same string. Harmless for the UI (it always passes canonical names back from the list APIs). Closing it needs the realtime session-key path too, so it's a follow-up rather than a drive-by.🤖 Generated with Claude Code
https://claude.ai/code/session_01KPybRBwDvgaL7bKCfVYeUD
Summary by CodeRabbit
New Features
Bug Fixes