From 527142ad2e289446e09fa0ab00021bd48347efa0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 23 Jun 2026 14:26:44 -0400 Subject: [PATCH] fix(#1615): normalize Windows backslash paths in workflow content computePathPrefix returned a Windows-style path (with backslashes from path.join) into markdown @-references. Workflow file content on Windows ended up with mixed separators, breaking substring checks in install/install-runtime-artifacts tests on windows-latest CI only. Normalize resolvedTarget and homeDir to forward slashes inside computePathPrefix. The prefix is always substituted into markdown body text, which uses POSIX paths universally. Idempotent on POSIX. Also normalizes the two test assertions to forward-slash form so they pass on Windows. Adds a regression test for backslash-style input. Documents DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT + RULESET.CONTENT-PATH-NORMALIZATION in CONTEXT.md so this anti-pattern stops recurring. --- CONTEXT.md | 8 ++++++++ src/runtime-artifact-conversion.cts | 14 +++++++++++--- .../enh-1511-rewrite-engine-relocation.test.cjs | 16 ++++++++++++++++ tests/install-runtime-artifacts.test.cjs | 2 +- tests/install.test.cjs | 2 +- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 95c20b236..544919edd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -722,6 +722,14 @@ A legal deferred state of an Execute step (`external_job_waiting`): the executor `DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh ) rather than relying on exec-bit; annotate // windows-portability-ok: when a bypass is intentional` `DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run lint:ci before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.symptom=path.join() result on Windows (backslashes) substituted verbatim into markdown body (@-references, workflow files, generated docs); content gains mixed separators; cross-platform substring assertions fail on windows-latest CI lane only; macOS/Linux CI green so defect ships undetected` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.examples=PR #1622 computePathPrefix returned ${resolvedTarget}/ verbatim — rewrites of @~/.claude/gsd-core/commands/gsd/X.md wrote @C:\...\gsd-ial-windsurf-XXX\gsd-core/commands/gsd/help.md (trailing forward slashes from the original literal survived, prefix backslashes did not); tests/install-runtime-artifacts.test.cjs:318 + tests/install.test.cjs:1323 failed on windows-latest only` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.detect=any function returning a filesystem path that flows into markdown/text body substitution; grep for path.join/raw resolvedTarget/${configDir}/ in code paths writing workflow .md, agent .md, or generated docs; smoke pattern is ${resolvedTarget}/ or ${configDir}/... templates that bypass normalization` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.fix-forward=normalize at the SOURCE not the test: posixTarget=String(resolvedTarget).replace(/\\/g,'/'), posixHome=homeDir?String(homeDir).replace(/\\/g,'/'):homeDir; markdown body is POSIX-only; .replace(/\\/g,'/') is idempotent on POSIX (no backslashes present) so safe to apply unconditionally; isWindowsHost arg is a no-op tripwire (enh-1511) — do NOT branch on it, normalize always` +`DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT.prevention=RULESET.CONTENT-PATH-NORMALIZATION; tests are downstream signal, never the fix; ref DEFECT.WINDOWS-TEST-PORTABILITY for test-side parity (normalize expected substrings too: ${configDir}/foo.replace(/\\/g,'/'))` + +`RULESET.CONTENT-PATH-NORMALIZATION=filesystem paths substituted into markdown body text (@-references, workflow .md, agent .md, generated docs, command bodies) MUST be normalized to POSIX forward slashes via .replace(/\\/g,'/') at the production source BEFORE substitution; never push normalization to tests; cross-platform content is POSIX-only; applies to: computePathPrefix output, install-path rewrites, generated shim paths emitted into .md bodies; idempotent on POSIX so unconditional` + --- diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 7f8f48d10..1f244cf5e 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2169,10 +2169,18 @@ function convertClaudeCommandToKiloSkill(content, skillName) { * @private — exported as `_computePathPrefix` for tests. */ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) { - if (isGlobal && resolvedTarget.startsWith(homeDir) && !isOpencode) { - return '$HOME' + resolvedTarget.slice(homeDir.length) + '/'; + // #1615: normalize Windows backslashes to forward slashes. This prefix is + // substituted into markdown @-references (e.g. Windsurf workflow files), + // which use POSIX paths universally. Idempotent on POSIX (no backslashes). + // Without this, path.join on Windows produces a backslash prefix that + // leaks into markdown content and breaks cross-platform substring checks. + // See DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT in CONTEXT.md. + const posixTarget = String(resolvedTarget).replace(/\\/g, '/'); + const posixHome = homeDir ? String(homeDir).replace(/\\/g, '/') : homeDir; + if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) { + return '$HOME' + posixTarget.slice(posixHome.length) + '/'; } - return `${resolvedTarget}/`; + return `${posixTarget}/`; } /** diff --git a/tests/enh-1511-rewrite-engine-relocation.test.cjs b/tests/enh-1511-rewrite-engine-relocation.test.cjs index 6321ed053..949e7c2b9 100644 --- a/tests/enh-1511-rewrite-engine-relocation.test.cjs +++ b/tests/enh-1511-rewrite-engine-relocation.test.cjs @@ -91,6 +91,22 @@ describe('_computePathPrefix', () => { assert.equal(withWindows, '$HOME/.cursor/'); assert.strictEqual(withWindows, withoutWindows); }); + + test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => { + // path.join on Windows produces backslashes; the returned prefix is + // substituted into markdown @-references which must use POSIX paths. + // Without normalization the backslashes leak into workflow file content + // and break substring checks on Windows CI. + const prefix = conversion._computePathPrefix({ + isGlobal: false, + isOpencode: false, + isWindowsHost: true, + resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf', + homeDir: 'C:\\Users\\runner', + }); + assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/'); + assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 863305e57..0132978d8 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -315,7 +315,7 @@ describe('installRuntimeArtifacts — windsurf workflows layout (#1615)', () => const helpContent = fs.readFileSync(path.join(workflowsDir, 'gsd-help.md'), 'utf8'); assert.ok(!helpContent.startsWith('---'), 'Windsurf workflows must be plain markdown, not SKILL.md frontmatter'); assert.match(helpContent, /# gsd-help/, 'workflow should identify the slash command it backs'); - assert.ok(helpContent.includes(`${configDir}/gsd-core/commands/gsd/help.md`), + assert.ok(helpContent.includes(`${configDir}/gsd-core/commands/gsd/help.md`.replace(/\\/g, '/')), 'workflow should reference the installed command body using the actual install target'); for (const fileName of fs.readdirSync(workflowsDir)) { diff --git a/tests/install.test.cjs b/tests/install.test.cjs index db2cb94ee..22ab9e9d6 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -1320,7 +1320,7 @@ describe('windsurf local install writes workflow slash commands (#1615)', () => const workflowFile = path.join(workflowsDir, workflowEntry.name); const content = fs.readFileSync(workflowFile, 'utf8'); assert.ok( - content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`), + content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`.replace(/\\/g, '/')), `${workflowEntry.name} must reference the installed canonical command body`, ); assert.ok(