Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/explanation/waves-lanes-and-worktrees.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/tutorials/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
18 changes: 11 additions & 7 deletions extensions/taskplane/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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/<taskDirName>/ 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
Expand Down
4 changes: 2 additions & 2 deletions extensions/taskplane/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion extensions/tests/execution-path-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\");
Expand Down
105 changes: 105 additions & 0 deletions extensions/tests/portable-task-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof resolveCanonicalTaskPaths>, 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);
});
});
23 changes: 21 additions & 2 deletions extensions/tests/workspace-config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────
Expand Down Expand Up @@ -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", () => {
Expand Down