diff --git a/docs/explanation/waves-lanes-and-worktrees.md b/docs/explanation/waves-lanes-and-worktrees.md index 00216ab7..570eba45 100644 --- a/docs/explanation/waves-lanes-and-worktrees.md +++ b/docs/explanation/waves-lanes-and-worktrees.md @@ -31,6 +31,10 @@ independently in each repo. Everything in this document applies to both modes. Sections that describe workspace-specific behavior are called out explicitly. +Task-marker lookup normalizes forward-slash and backslash separators before +resolving task and worktree paths. Mixed separators therefore locate the same +`STATUS.md` and `.DONE` files, including the archive fallback. + --- ## 1) Dependency graph diff --git a/docs/tutorials/install.md b/docs/tutorials/install.md index e6d5c7c2..65f2c772 100644 --- a/docs/tutorials/install.md +++ b/docs/tutorials/install.md @@ -146,6 +146,10 @@ taskplane init Init detects the subdirectory repos and prompts you to choose which one holds the shared Taskplane config. The selected repo gets a `.taskplane/` directory with all config, and the workspace root gets a pointer file (`.pi/taskplane-pointer.json`) that tells Taskplane where to find it. +The pointer's `config_path` must be relative to the selected config repo. Both +forward-slash and backslash separators are accepted, but POSIX absolute paths, +Windows drive-absolute paths, and UNC paths are rejected on every host platform. + Files created in the config repo (e.g., `repo-a`): - `repo-a/.taskplane/taskplane-config.json` diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index 1065456b..cdf2ac2a 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -436,8 +436,12 @@ export function resolveCanonicalTaskPaths( repoRoot: string, isWorkspaceMode?: boolean, ): ResolvedTaskPaths { - const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/"); - const folderNorm = resolve(taskFolder).replace(/\\/g, "/"); + // Normalize separators before native path resolution. On POSIX, a leading + // backslash is otherwise treated as a relative filename and prefixed with cwd. + const repoRootNorm = resolve(repoRoot.replace(/\\/g, "/")).replace(/\\/g, "/"); + const folderPath = resolve(taskFolder.replace(/\\/g, "/")); + const folderNorm = folderPath.replace(/\\/g, "/"); + const worktreeRoot = resolve(worktreePath.replace(/\\/g, "/")); let resolvedFolder: string; @@ -447,21 +451,21 @@ export function resolveCanonicalTaskPaths( // the worktree, so the engine must look there too. if (folderNorm.startsWith(repoRootNorm + "/")) { const relPath = folderNorm.slice(repoRootNorm.length + 1); - resolvedFolder = join(worktreePath, relPath); + resolvedFolder = join(worktreeRoot, relPath); } else { // Cross-repo: task files were copied into the worktree under // .taskplane-tasks// by buildLaneEnvVars - const taskDirName = basename(resolve(taskFolder)); - resolvedFolder = join(worktreePath, ".taskplane-tasks", taskDirName); + const taskDirName = basename(folderPath); + resolvedFolder = join(worktreeRoot, ".taskplane-tasks", taskDirName); } } else if (folderNorm.startsWith(repoRootNorm + "/")) { // Repo mode: task folder is inside the repo root. // Translate to equivalent path in the worktree. const relativePath = folderNorm.slice(repoRootNorm.length + 1); - resolvedFolder = join(worktreePath, relativePath); + resolvedFolder = join(worktreeRoot, relativePath); } else { // Fallback: use absolute path directly. - resolvedFolder = resolve(taskFolder); + resolvedFolder = folderPath; } // Check primary location diff --git a/extensions/taskplane/workspace.ts b/extensions/taskplane/workspace.ts index 58faddc0..3b18aba6 100644 --- a/extensions/taskplane/workspace.ts +++ b/extensions/taskplane/workspace.ts @@ -40,7 +40,7 @@ * @module orch/workspace */ import { readFileSync, existsSync, realpathSync } from "fs"; -import { resolve, relative, isAbsolute } from "path"; +import { resolve, relative, isAbsolute, win32 } from "path"; import { parse as yamlParse } from "yaml"; import { runGit } from "./git.ts"; @@ -226,7 +226,7 @@ export function resolvePointer( const normalizedConfigPath = configPath.trim().replace(/\\/g, "/"); // Reject absolute paths (POSIX `/...` and Windows `C:/...`, `\\...`) - if (isAbsolute(normalizedConfigPath) || isAbsolute(configPath.trim())) { + if (isAbsolute(normalizedConfigPath) || win32.isAbsolute(normalizedConfigPath)) { return { used: false, configRoot: fallbackConfigRoot, diff --git a/extensions/tests/execution-path-resolution.test.ts b/extensions/tests/execution-path-resolution.test.ts index a55a895a..48015222 100644 --- a/extensions/tests/execution-path-resolution.test.ts +++ b/extensions/tests/execution-path-resolution.test.ts @@ -542,7 +542,8 @@ function runEdgeCaseTests(): void { const wtMirror = join(worktreePath, "tasks", "TP-WIN"); mkdirSync(wtMirror, { recursive: true }); - // Use backslash-style paths (if on Windows this is natural; on unix resolve() normalizes anyway) + // Use backslash-style paths on every platform. POSIX resolve() does not + // interpret backslashes, so the task resolver must normalize them first. const backslashTask = taskFolder.replace(/\//g, "\\"); const backslashRepo = repoRoot.replace(/\//g, "\\"); const backslashWt = worktreePath.replace(/\//g, "\\"); diff --git a/extensions/tests/portable-task-paths.test.ts b/extensions/tests/portable-task-paths.test.ts new file mode 100644 index 00000000..65e4e01a --- /dev/null +++ b/extensions/tests/portable-task-paths.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import { strict as assert } from "node:assert"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCanonicalTaskPaths } from "../taskplane/execution.ts"; +import { resolvePointer } from "../taskplane/workspace.ts"; +import type { WorkspaceConfig } from "../taskplane/types.ts"; + +describe("portable task and pointer paths", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "tp-portable-paths-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const backslashes = (path: string) => path.replace(/\//g, "\\"); + const forwardSlashes = (path: string) => path.replace(/\\/g, "/"); + + function writeMarkers(folder: string) { + mkdirSync(folder, { recursive: true }); + writeFileSync(join(folder, ".DONE"), "done"); + writeFileSync(join(folder, "STATUS.md"), "completed"); + } + + function assertMarkers(result: ReturnType, folder: string) { + assert.equal(result.taskFolderResolved, folder); + assert.equal(result.donePath, join(folder, ".DONE")); + assert.equal(result.statusPath, join(folder, "STATUS.md")); + assert.ok(existsSync(result.donePath)); + assert.ok(existsSync(result.statusPath)); + } + + it("rejects Windows absolute pointer paths in the Linux CI test selection", () => { + mkdirSync(join(root, ".pi")); + const config = { + repos: new Map([["infra", { path: join(root, "infra") }]]), + } as unknown as WorkspaceConfig; + for (const configPath of ["C:/config", "D:\\config", "\\\\server\\share\\config"]) { + writeFileSync( + join(root, ".pi", "taskplane-pointer.json"), + JSON.stringify({ config_repo: "infra", config_path: configPath }), + ); + const result = resolvePointer(root, config); + assert.equal(result?.used, false, configPath); + assert.match(result?.warning ?? "", /absolute paths not allowed/); + assert.equal(result?.configRoot, join(root, ".pi")); + } + }); + + for (const workspaceMode of [false, true]) { + it(`finds worktree markers when input separators differ (workspace=${workspaceMode})`, () => { + const repo = join(root, "repo"); + const worktree = join(root, "worktree"); + const expected = join(worktree, "tasks", "TEST-001"); + writeMarkers(expected); + const result = resolveCanonicalTaskPaths( + backslashes(join(repo, "tasks", "TEST-001")), + backslashes(worktree), + forwardSlashes(repo), + workspaceMode, + ); + assertMarkers(result, expected); + }); + } + + it("keeps external task markers at their canonical location outside workspace mode", () => { + const externalTask = join(root, "external", "TEST-002"); + writeMarkers(externalTask); + const result = resolveCanonicalTaskPaths( + backslashes(externalTask), + join(root, "worktree"), + join(root, "repo"), + ); + assertMarkers(result, externalTask); + }); + + it("uses the task directory name for cross-repo workspace copies", () => { + const worktree = join(root, "worktree"); + const expected = join(worktree, ".taskplane-tasks", "TEST-003"); + writeMarkers(expected); + const result = resolveCanonicalTaskPaths( + backslashes(join(root, "packet-repo", "tasks", "TEST-003")), + backslashes(worktree), + join(root, "execution-repo"), + true, + ); + assertMarkers(result, expected); + }); + + it("finds archived markers after translating the worktree path", () => { + const repo = join(root, "repo"); + const worktree = join(root, "worktree"); + const archived = join(worktree, "tasks", "archive", "TEST-004"); + writeMarkers(archived); + const result = resolveCanonicalTaskPaths( + backslashes(join(repo, "tasks", "TEST-004")), + backslashes(worktree), + backslashes(repo), + ); + assertMarkers(result, archived); + }); +}); diff --git a/extensions/tests/workspace-config.integration.test.ts b/extensions/tests/workspace-config.integration.test.ts index c9527ae8..4c49f046 100644 --- a/extensions/tests/workspace-config.integration.test.ts +++ b/extensions/tests/workspace-config.integration.test.ts @@ -996,9 +996,18 @@ describe("resolvePointer", () => { const result = resolvePointer(dir, wsConfig); expect(result!.used).toBe(false); - // May be caught by absolute check or containment check + expect(result!.warning).toContain("absolute paths not allowed"); + }); + + it("6.8d: rejects UNC config_path on every host platform", () => { + const dir = makeTestDir("ptr-abs-unc"); + writePointer( + dir, + JSON.stringify({ config_repo: "infra", config_path: "\\\\server\\share\\config" }), + ); + const result = resolvePointer(dir, makeWorkspaceConfig({ infra: "/fake/infra" })); expect(result!.used).toBe(false); - expect(result!.warning).toBeDefined(); + expect(result!.warning).toContain("absolute paths not allowed"); }); // ── 6.9: Valid pointer → resolved paths ───────────────────── @@ -1031,6 +1040,16 @@ describe("resolvePointer", () => { expect(result!.agentRoot).toBe(resolve(repoPath, "config", "taskplane", "agents")); }); + it("6.9c: accepts relative config_path with backslash separators", () => { + const dir = makeTestDir("ptr-relative-backslashes"); + const repoPath = resolve(dir, "infra-repo"); + writePointer(dir, JSON.stringify({ config_repo: "infra", config_path: "config\\taskplane" })); + const result = resolvePointer(dir, makeWorkspaceConfig({ infra: repoPath })); + expect(result!.used).toBe(true); + expect(result!.configRoot).toBe(resolve(repoPath, "config", "taskplane")); + expect(result!.agentRoot).toBe(resolve(repoPath, "config", "taskplane", "agents")); + }); + // ── 6.10: Defense-in-depth containment check ──────────────── it("6.10: containment check catches resolved path escaping repo root", () => {