pi-crew 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,137 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.2] — package cleanup: remove scratch scripts leaked into npm tarball (2026-06-22)
4
+
5
+ Patch release. **No code changes.** Purely a published-package hygiene fix.
6
+
7
+ ### What was wrong
8
+
9
+ The `package.json` `files` field includes a root-level `*.mjs` glob, intended
10
+ for the legit `install.mjs` (postinstall script). It accidentally also picked
11
+ up three ad-hoc scratch scripts that were committed at the repo root during
12
+ earlier development:
13
+
14
+ - `test-tp.mjs` — a 12-line `tool-progress` format smoke print
15
+ - `test-bugs-all.mjs` — an 89-line string-matching bug-fix verifier
16
+ - `test-lastActivityAt.mjs` — a manual `node:test` for a heartbeat fallback
17
+
18
+ All three shipped in the published npm tarball (v0.9.0 and v0.9.1) even though
19
+ they are not part of `npm test` and have no runtime value for consumers.
20
+
21
+ ### Fix
22
+
23
+ - `git rm`'d the three scratch scripts. The behavior they checked is covered by
24
+ the real unit suite (`test/unit/`), which does NOT ship to npm.
25
+ - After the fix, the only `.mjs` in the tarball is the intended `install.mjs`.
26
+
27
+ ### Verification
28
+
29
+ - tsc: 0
30
+ - `npm pack --dry-run` confirms `install.mjs` is the only `.mjs` shipped
31
+ - regression suite (env-allowlist, goal-p1d-schema): 11/11 pass
32
+ - No behavior change — consumers see no functional difference
33
+
34
+ ### Breaking changes
35
+
36
+ None.
37
+
38
+ ---
39
+
40
+ ## [v0.9.1] — Windows essentials fix + cross-platform CI green (2026-06-22)
41
+
42
+ Patch release. No new features. Fixes a real Windows bug reported by a user,
43
+ plus the cross-platform CI failures that followed.
44
+
45
+ ### fix(windows): `${APPDATA}` npm-global resolution failure (root cause)
46
+
47
+ Reported symptom (Windows): running pi-crew created a phantom literal
48
+ `${APPDATA}/npm/` directory in the project root (containing `node_modules`,
49
+ `pi-crew`, `pi-crew.cmd`, `pi-crew.ps1`) and leaked a literal `${APPDATA}`
50
+ line into `.gitignore`.
51
+
52
+ Root cause: pi-crew's subprocess env sanitization used explicit allowlists
53
+ that stripped **all Windows-essential env vars** (`APPDATA`, `LOCALAPPDATA`,
54
+ `USERPROFILE`, `SystemRoot`, `ComSpec`, `TEMP`, `TMP`). When a child pi
55
+ process (or npm inside it) tried to resolve the npm-global prefix on Windows,
56
+ it used `%APPDATA%` (cmd expansion) / `${APPDATA}` (bash expansion), but
57
+ `APPDATA` was missing from the env — so the shell left the literal
58
+ `${APPDATA}` in place and operations created/ignored paths under that
59
+ literal name.
60
+
61
+ Fix: added the 7 Windows essentials to all 7 subprocess env allowlists
62
+ (child-pi, async-runner, verification-gates, post-checks, iteration-hooks,
63
+ worktree/cleanup, worktree/worktree-manager). (commit `a7ddc50`)
64
+
65
+ ### refactor(env): centralize Windows essentials + regression guard
66
+
67
+ The same 7 vars were duplicated inline across 9 call sites — easy to forget
68
+ on a new allowlist, with nothing preventing a future site from omitting them.
69
+
70
+ - New single source of truth: `WINDOWS_ESSENTIAL_ENV_VARS` in
71
+ `src/utils/env-allowlist.ts` (with full root-cause documentation).
72
+ - All 9 call sites now spread the constant instead of inlining (net −42/+19
73
+ lines, behavior unchanged).
74
+ - New regression test `test/unit/env-allowlist.test.ts`: scans ALL
75
+ `src/**/*.ts` files and fails if any hardcodes the 7 vars inline (the only
76
+ allowed location is the constant file). This catches any new allowlist that
77
+ forgets the constant — the exact regression that caused the bug.
78
+ (commit `6a0284c`)
79
+
80
+ ### fix(ci): cross-platform CI green (ubuntu + macOS + Windows)
81
+
82
+ Three distinct containment/path bugs that only surfaced on non-ubuntu CI:
83
+
84
+ 1. **Windows 8.3 short-name paths** — `resolveWindowsCanonical()` used
85
+ non-native `realpathSync`, preserving the `RUNNER~1` vs `runneradmin`
86
+ form mismatch. A legitimately-contained dynamic workflow file was
87
+ rejected as "outside the allowed directories". Fixed by using
88
+ `realpathSync.native` (canonical long-name form) as the primary resolver.
89
+ (commit `e9e7137`)
90
+
91
+ 2. **ESM `file://` URLs** — two integration tests passed raw Windows paths
92
+ (`D:\…`) to native `import()`, which Node rejects on Windows
93
+ (`ERR_UNSUPPORTED_ESM_URL_SCHEME: protocol 'd:'`). Wrapped with
94
+ `pathToFileURL(…).href`. (commit `e9e7137`)
95
+
96
+ 3. **macOS symlink-ancestor** — `isSymlinkSafePath()` walked up the temp
97
+ path and hit `/var` (a symlink → `/private/var`). The old check compared
98
+ the resolved `/private/var` against the tmpdir
99
+ `/private/var/folders/…/T` — `/private/var` is an **ancestor**, not a
100
+ descendant, so it was wrongly rejected (5 macOS worker-atomic-writer
101
+ failures). Fixed by accepting a symlink whose target is a safe root, is
102
+ UNDER a safe root, OR is an ANCESTOR of a safe root. Added two behavioral
103
+ regression tests (symlink-ancestor accept + symlink-attack reject).
104
+ (commit `e9e7137`)
105
+
106
+ 4. **macOS `/var` containment** — `resolveContainedPath()` only
107
+ canonicalized paths on win32; on POSIX it compared raw paths, so base
108
+ (`/private/var`) vs target (`/var`) diverged → false "outside" rejection
109
+ (macOS dwf-setresult failure). Added platform-agnostic
110
+ `resolveCanonicalPath()`. Added a darwin-only regression test for the
111
+ real `/var` divergence. (commit `4821bb1`)
112
+
113
+ 5. **Windows wakeup timing** — `subagent-manager` polls the child run
114
+ manifest every 1000ms. On the slower Windows CI runner, child-process
115
+ spawn + first poll exceeded the test's 10s deadline (failed at 11.6s).
116
+ Bumped the mock-test deadline to 30s. (commit `4821bb1`)
117
+
118
+ ### Verification
119
+
120
+ - tsc: 0
121
+ - Full test suite: 5207 tests, 0 fail on **all three** platforms (ubuntu,
122
+ macOS, Windows)
123
+ - CI run `27955398241`: success across ubuntu-latest, macos-latest,
124
+ windows-latest
125
+ - Regression tests added: env-allowlist scan, worker-atomic-writer symlink
126
+ ancestor/attack, safe-paths darwin `/var` divergence
127
+
128
+ ### Breaking changes
129
+
130
+ None. All fixes are additive or behavior-preserving. Windows users who hit
131
+ the `${APPDATA}` bug should upgrade.
132
+
133
+ ---
134
+
3
135
  ## [v0.9.0] — goal loops + dynamic workflows (2026-06-18)
4
136
 
5
137
  Two new features, both built on a shared `runKind` background-dispatch discriminator.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
7
8
  import { appendEvent } from "../state/event-log.ts";
8
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -183,6 +184,8 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
183
184
  "XDG_DATA_HOME",
184
185
  "XDG_CACHE_HOME",
185
186
  "XDG_RUNTIME_DIR",
187
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
188
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
186
189
  "NVM_BIN",
187
190
  "NVM_DIR",
188
191
  "NVM_INC",
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import type { AgentConfig } from "../agents/agent-config.ts";
5
6
  import type { WorkerExitStatus } from "../state/types.ts";
6
7
  import { buildPiWorkerArgs, checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
@@ -245,6 +246,8 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
245
246
  "XDG_DATA_HOME",
246
247
  "XDG_CACHE_HOME",
247
248
  "XDG_RUNTIME_DIR",
249
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
250
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
248
251
  "NVM_BIN",
249
252
  "NVM_DIR",
250
253
  "NVM_INC",
@@ -7,6 +7,7 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
10
11
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
11
12
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
13
  import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
@@ -171,7 +172,7 @@ export async function runIterationHook(
171
172
  const { command, args } = resolveShellForScript(resolvedScript);
172
173
  const child = spawn(command, args, {
173
174
  cwd: payload.cwd,
174
- env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
175
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
175
176
  stdio: ["pipe", "pipe", "pipe"],
176
177
  });
177
178
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
8
  import * as path from "node:path";
9
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
9
10
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
10
11
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
11
12
 
@@ -94,7 +95,7 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
94
95
  timeout: timeoutMs,
95
96
  encoding: "utf-8",
96
97
  maxBuffer: 10 * 1024 * 1024, // 10 MB
97
- env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_POST_CHECK: "1" },
98
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL", "PI_CREW_*"] }), PI_CREW_POST_CHECK: "1" },
98
99
  });
99
100
 
100
101
  const durationMs = Date.now() - startTime;
@@ -12,6 +12,7 @@
12
12
  import { spawn } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
15
16
  import { writeArtifact } from "../state/artifact-store.ts";
16
17
  import { redactSecretString } from "../utils/redaction.ts";
17
18
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -48,6 +49,8 @@ const VERIFICATION_ENV_ALLOWLIST: readonly string[] = [
48
49
  "XDG_DATA_HOME",
49
50
  "XDG_CACHE_HOME",
50
51
  "XDG_RUNTIME_DIR",
52
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
53
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
51
54
  "NVM_BIN",
52
55
  "NVM_DIR",
53
56
  "NVM_INC",
@@ -40,24 +40,38 @@ const crypto = require("node:crypto");
40
40
 
41
41
  function isSymlinkSafePath(filePath) {
42
42
  try {
43
- let currentPath = filePath;
43
+ // Safe roots: the system temp dir (resolved through any symlinks, e.g.
44
+ // macOS /var → /private/var) and the current project cwd.
45
+ var tmpReal;
46
+ try { tmpReal = fs.realpathSync(require("node:os").tmpdir()); } catch (e2) { tmpReal = require("node:os").tmpdir(); }
47
+ var cwd = process.cwd();
48
+ // Accept a symlink ancestor as safe if the resolved target (a) IS a safe
49
+ // root, (b) is UNDER a safe root (target is a descendant), or (c) is an
50
+ // ANCESTOR of a safe root (target is a parent dir of a safe root).
51
+ // Case (c) is essential on macOS: /var → /private/var is an ancestor of
52
+ // the tmpdir /private/var/folders/…, so it must be allowed.
53
+ var isSafeAncestor = function (real) {
54
+ var roots = [tmpReal, cwd];
55
+ return roots.some(function (root) {
56
+ return real === root
57
+ || real.indexOf(root + path.sep) === 0
58
+ || root.indexOf(real + path.sep) === 0;
59
+ });
60
+ };
61
+ var currentPath = filePath;
44
62
  while (currentPath !== path.dirname(currentPath)) {
45
- const dir = path.dirname(currentPath);
63
+ var dir = path.dirname(currentPath);
46
64
  try {
47
- const stat = fs.lstatSync(dir);
65
+ var stat = fs.lstatSync(dir);
48
66
  if (stat.isSymbolicLink()) {
49
- // Accept symlinks under /tmp (macOS /var/folders) and project dirs;
50
- // reject others. Mirrors atomic-write.ts policy for goal-loop paths.
51
- const real = fs.realpathSync(dir);
52
- if (!real.startsWith("/tmp/") && !real.startsWith(process.cwd())) {
53
- return false;
54
- }
67
+ var real = fs.realpathSync(dir);
68
+ if (!isSafeAncestor(real)) return false;
55
69
  }
56
- } catch { /* not found — fine */ }
70
+ } catch (e3) { /* not found — fine */ }
57
71
  currentPath = dir;
58
72
  }
59
73
  return true;
60
- } catch { return true; }
74
+ } catch (e) { return true; }
61
75
  }
62
76
 
63
77
  function syncAtomicWriteFile(filePath, content) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Windows-essential environment variables that MUST be present in every
3
+ * subprocess env allowlist in pi-crew.
4
+ *
5
+ * Without them, child processes on Windows cannot locate the npm-global prefix
6
+ * (`%APPDATA%\npm`), the user profile, system DLLs (SystemRoot), cmd.exe
7
+ * (ComSpec), or a writable temp dir.
8
+ *
9
+ * Regression root cause (v0.9.0): env allowlists stripped these vars, so child
10
+ * pi/npm resolved the npm-global prefix via the literal `%APPDATA%` (cmd) /
11
+ * `${APPDATA}` (bash) expansion — but APPDATA was missing from the env, so the
12
+ * shell left the literal `${APPDATA}` in place. A phantom `${APPDATA}/npm`
13
+ * directory appeared in the project root and a literal `${APPDATA}` line leaked
14
+ * into `.gitignore`.
15
+ *
16
+ * USAGE: spread this constant into every `allowList` array:
17
+ * `allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, ...]`
18
+ *
19
+ * The regression test `test/unit/env-allowlist.test.ts` enforces that no
20
+ * `src/` file hardcodes these vars inline — all sites MUST use this constant.
21
+ */
22
+ export const WINDOWS_ESSENTIAL_ENV_VARS: readonly string[] = [
23
+ "APPDATA",
24
+ "LOCALAPPDATA",
25
+ "USERPROFILE",
26
+ "SystemRoot",
27
+ "ComSpec",
28
+ "TEMP",
29
+ "TMP",
30
+ ];
@@ -16,32 +16,73 @@ export function resolveContainedPath(baseDir: string, targetPath: string): strin
16
16
  }
17
17
  const base = path.resolve(baseDir);
18
18
  const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
19
- // On Windows, paths are case-insensitive and short-name (8.3) aliases may
20
- // differ from long-name forms (e.g. C:\Users\RUNNER~1 vs C:\Users\runneradmin).
21
- // We normalize both paths to their canonical form by resolving through
22
- // realpathSync, walking up ancestors for non-existent paths.
23
- const baseNorm = process.platform === "win32" ? resolveWindowsCanonical(base) : base;
24
- const resolvedNorm = process.platform === "win32" ? resolveWindowsCanonical(resolved) : resolved;
19
+ // Normalize BOTH paths to canonical form on ALL platforms. This resolves
20
+ // symlinks so that a base and target that refer to the same physical dir
21
+ // via different paths (Windows 8.3 short-name; macOS /var /private/var)
22
+ // compare equal. Without this, a legitimately-contained target under an
23
+ // OS-managed symlink is wrongly rejected as "outside" the base.
24
+ const baseNorm = resolveCanonicalPath(base);
25
+ const resolvedNorm = resolveCanonicalPath(resolved);
25
26
  const relative = process.platform === "win32"
26
27
  ? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
27
- : path.relative(base, resolved);
28
+ : path.relative(baseNorm, resolvedNorm);
28
29
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
29
30
  return resolved;
30
31
  }
31
32
 
33
+ /**
34
+ * Platform-agnostic canonical path resolution. Resolves symlinks (via
35
+ * realpathSync) to normalize paths so the same physical location always
36
+ * yields the same string regardless of how it was referenced.
37
+ *
38
+ * - Windows: delegates to resolveWindowsCanonical (canonical LONG-NAME form,
39
+ * resolving 8.3 short-name aliases like RUNNER~1 → runneradmin).
40
+ * - POSIX: uses realpathSync, which resolves system symlinks such as the
41
+ * macOS /var → /private/var mapping.
42
+ *
43
+ * For non-existent paths (write targets), walks up to the deepest existing
44
+ * ancestor and joins the remaining components, so the canonical prefix is
45
+ * still comparable.
46
+ */
47
+ function resolveCanonicalPath(p: string): string {
48
+ if (process.platform === "win32") return resolveWindowsCanonical(p);
49
+ try {
50
+ return fs.realpathSync(p);
51
+ } catch {
52
+ const parts: string[] = [];
53
+ let current = p;
54
+ while (current !== path.dirname(current)) {
55
+ try {
56
+ const real = fs.realpathSync(current);
57
+ let acc = real;
58
+ for (let i = parts.length - 1; i >= 0; i--) acc = path.join(acc, parts[i]);
59
+ return acc;
60
+ } catch { /* keep walking up */ }
61
+ parts.push(path.basename(current));
62
+ current = path.dirname(current);
63
+ }
64
+ return p;
65
+ }
66
+ }
67
+
32
68
  /**
33
69
  * On Windows, resolve a path to its canonical (long-name) form.
34
70
  * Walks up ancestors until finding one that exists, then joins back down.
35
71
  * This handles paths where intermediate directories don't exist yet but
36
72
  * their ancestors do (and may use short-name aliases).
73
+ *
74
+ * Uses fs.realpathSync.native (canonical long-name form) as the primary
75
+ * resolver, falling back to non-native realpathSync if .native fails.
37
76
  */
38
77
  function resolveWindowsCanonical(p: string): string {
39
78
  try {
40
- // Use regular realpathSync (not .native) to preserve the input path form.
41
- // On Windows CI, .native always returns long-name (runneradmin) while
42
- // non-native preserves short-name (RUNNER~1). Using non-native ensures
43
- // the returned form matches what os.tmpdir() and mkdtempSync produce.
44
- let real = fs.realpathSync(p);
79
+ // Use the NATIVE realpath to resolve to canonical LONG-NAME form.
80
+ // On Windows, fs.realpathSync.native resolves 8.3 short-name aliases
81
+ // (e.g. RUNNER~1) to long-name (runneradmin). This is essential for
82
+ // containment checks: base (from cwd, often long-name) and target
83
+ // (from os.tmpdir()/mkdtempSync, often short-name) must normalize to
84
+ // the SAME form or a contained target is wrongly rejected as "outside".
85
+ let real = fs.realpathSync.native(p);
45
86
  // Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
46
87
  if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
47
88
  return real;
@@ -56,8 +97,8 @@ function resolveWindowsCanonical(p: string): string {
56
97
  let current = p;
57
98
  while (current !== path.dirname(current)) {
58
99
  try {
59
- // Use non-native to preserve input path form
60
- let real = fs.realpathSync(current);
100
+ let real: string;
101
+ try { real = fs.realpathSync.native(current); } catch { real = fs.realpathSync(current); }
61
102
  // Guard against NTFS internal paths
62
103
  if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
63
104
  // Found existing ancestor — join with remaining parts in reverse order
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import type { TeamRunManifest } from "../state/types.ts";
5
6
  import { writeArtifact } from "../state/artifact-store.ts";
6
7
  import { projectCrewRoot } from "../utils/paths.ts";
@@ -18,7 +19,7 @@ export interface WorktreeCleanupResult {
18
19
 
19
20
  // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
20
21
  // Git operations do not need PI_CREW_* execution-control vars.
21
- const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
22
+ const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
22
23
 
23
24
  function sanitizeBranchPart(value: string): string {
24
25
  return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import { loadConfig } from "../config/config.ts";
5
6
  import { projectCrewRoot } from "../utils/paths.ts";
6
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
@@ -27,7 +28,7 @@ export interface WorktreeDiffStat {
27
28
  function git(cwd: string, args: string[]): string {
28
29
  // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
29
30
  // Git operations do not need PI_CREW_* execution-control vars.
30
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
31
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
31
32
  }
32
33
 
33
34
  // Dots are removed from branch names since they are used in path construction,
@@ -182,7 +183,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
182
183
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
183
184
  shell: false, // cmd.exe /c handles batch files safely
184
185
  env: sanitizeEnvSecrets(process.env, {
185
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
186
+ allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
186
187
  }),
187
188
  windowsHide: true,
188
189
  })
@@ -193,7 +194,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
193
194
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
194
195
  shell: false,
195
196
  env: sanitizeEnvSecrets(process.env, {
196
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
197
+ allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
197
198
  }),
198
199
  windowsHide: true,
199
200
  });
package/test-bugs-all.mjs DELETED
@@ -1,89 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- console.log("=== PI-CREW BUG FIXES VERIFICATION ===\n");
5
-
6
- let allPassed = true;
7
-
8
- // Bug #17: Check killAsync is commented out
9
- console.log("Bug #17: Background runner session shutdown fix");
10
- const registerContent = fs.readFileSync("src/extension/register.ts", "utf-8");
11
- const killAsyncMatch = registerContent.match(/\/\/\s*for\s*\(\s*const\s+manifest\s+of\s+manifestCache\.list\(50\)/);
12
- if (killAsyncMatch) {
13
- console.log(" ✅ killAsync loop is commented out");
14
- } else if (registerContent.includes("for (const manifest of manifestCache.list(50))") && !registerContent.includes("// for (const manifest")) {
15
- console.log(" ❌ killAsync loop is NOT commented out - BUG NOT FIXED");
16
- allPassed = false;
17
- } else {
18
- console.log(" ✅ killAsync pattern not found (may have been refactored)");
19
- }
20
-
21
- // Bug #18: Check stdio is ["ignore", "pipe", "pipe"]
22
- console.log("\nBug #18: Child-pi stdin fix");
23
- const childPiContent = fs.readFileSync("src/runtime/child-pi.ts", "utf-8");
24
- const stdioMatch = childPiContent.match(/stdio:\s*\[\s*"ignore"\s*,\s*"pipe"\s*,\s*"pipe"\s*\]/);
25
- if (stdioMatch) {
26
- console.log(" ✅ stdio is ['ignore', 'pipe', 'pipe']");
27
- } else if (childPiContent.includes('stdio: ["pipe", "pipe", "pipe"]')) {
28
- console.log(" ❌ stdio is still ['pipe', 'pipe', 'pipe'] - BUG NOT FIXED");
29
- allPassed = false;
30
- } else {
31
- console.log(" ⚠️ stdio pattern not found in expected format");
32
- }
33
-
34
- // Bug #19: Check temp workspace cleanup
35
- console.log("\nBug #19: Phantom runs temp workspace fix");
36
- const runIndexContent = fs.readFileSync("src/extension/run-index.ts", "utf-8");
37
- const tempDirCheck = runIndexContent.includes("isTempRoot") || runIndexContent.includes("tmpdir") || runIndexContent.includes("tmpDir");
38
- const activeRunContent = fs.readFileSync("src/state/active-run-registry.ts", "utf-8");
39
- const timeoutCheck = activeRunContent.includes("30 * 60 * 1000") || activeRunContent.includes("30*60*1000");
40
- if (tempDirCheck && timeoutCheck) {
41
- console.log(" ✅ Temp workspace detection and 30-min timeout present");
42
- } else if (!tempDirCheck) {
43
- console.log(" ❌ Temp workspace detection NOT found - BUG NOT FIXED");
44
- allPassed = false;
45
- } else if (!timeoutCheck) {
46
- console.log(" ❌ 30-min timeout NOT found - BUG NOT FIXED");
47
- allPassed = false;
48
- }
49
-
50
- // Bug #20: Check needs_attention in completedIds
51
- console.log("\nBug #20: Infinite retry loop fix");
52
- const teamRunnerContent = fs.readFileSync("src/runtime/team-runner.ts", "utf-8");
53
- const needsAttentionMatch = teamRunnerContent.match(/status\s*===\s*"needs_attention"/g);
54
- if (needsAttentionMatch && needsAttentionMatch.length >= 3) {
55
- console.log(" ✅ needs_attention status checks found (" + needsAttentionMatch.length + " places)");
56
- } else {
57
- console.log(" ❌ needs_attention status check NOT found or insufficient - BUG NOT FIXED");
58
- allPassed = false;
59
- }
60
-
61
- // Check the specific completedIds fix
62
- const completedIdsFix = teamRunnerContent.includes('status === "completed" || t.status === "needs_attention"');
63
- if (completedIdsFix) {
64
- console.log(" ✅ completedIds includes needs_attention");
65
- } else {
66
- console.log(" ❌ completedIds does NOT include needs_attention - BUG NOT FIXED");
67
- allPassed = false;
68
- }
69
-
70
- // Check dist file
71
- console.log("\n=== Checking dist/index.mjs ===");
72
- if (fs.existsSync("dist/index.mjs")) {
73
- const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
74
- const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
75
- if (distNeedsAttention) {
76
- console.log(" ✅ Bug #20 fix is in dist/index.mjs");
77
- } else {
78
- console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
79
- allPassed = false;
80
- }
81
- } else {
82
- console.log(" ⚠️ dist/index.mjs not found - run npm run build first");
83
- }
84
-
85
- console.log("\n" + "=".repeat(40));
86
- console.log(allPassed ? "✅ ALL BUGS ARE FIXED" : "❌ SOME BUGS ARE NOT FIXED");
87
- console.log("=".repeat(40));
88
-
89
- process.exit(allPassed ? 0 : 1);
@@ -1,167 +0,0 @@
1
- /**
2
- * Test for lastActivityAt fallback in heartbeat-watcher
3
- * Verifies that tasks with stale heartbeat but recent lastActivityAt are not marked dead
4
- */
5
-
6
- import test from "node:test";
7
- import assert from "node:assert/strict";
8
- import * as fs from "node:fs";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import { createMetricRegistry } from "./src/observability/metric-registry.ts";
12
- import { HeartbeatWatcher } from "./src/runtime/heartbeat-watcher.ts";
13
- import { createRunManifest, saveRunTasks, updateRunStatus } from "./src/state/state-store.ts";
14
- import { createManifestCache } from "./src/runtime/manifest-cache.ts";
15
-
16
- const team = { name: "t", description: "", source: "test", filePath: "t", roles: [{ name: "r", agent: "a" }] };
17
- const workflow = { name: "w", description: "", source: "test", filePath: "w", steps: [{ id: "s", role: "r", task: "x" }] };
18
-
19
- test("HeartbeatWatcher uses lastActivityAt fallback - task NOT dead when heartbeat stale but activity recent", () => {
20
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-lastactivity-at-"));
21
- try {
22
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
23
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
24
- const manifest = updateRunStatus(created.manifest, "running", "running");
25
-
26
- // Create task with STALE heartbeat (old lastSeenAt) but RECENT lastActivityAt
27
- // Heartbeat is from Jan 1, 2026 (stale - 10 minutes old)
28
- // lastActivityAt is from Jan 1, 2026 00:08:00 (2 minutes old - within dead threshold of 5 minutes)
29
- const tasksWithHeartbeat = created.tasks.map((task) => ({
30
- ...task,
31
- status: "running",
32
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
33
- // Agent is still active - lastActivityAt is recent (within dead threshold)
34
- agentProgress: {
35
- lastActivityAt: "2026-01-01T00:08:00.000Z", // 2 minutes ago
36
- currentTool: "working",
37
- toolCount: 5,
38
- tokens: 1000,
39
- turns: 2
40
- }
41
- }));
42
-
43
- saveRunTasks(manifest, tasksWithHeartbeat);
44
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
45
- const notifications = [];
46
- let deadletters = 0;
47
- const watcher = new HeartbeatWatcher({
48
- cwd,
49
- manifestCache: cache,
50
- registry: createMetricRegistry(),
51
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
52
- deadletterTickThreshold: 3,
53
- onDeadletterTrigger: () => { deadletters += 1; }
54
- });
55
-
56
- // Simulate time at 00:10:00 - 10 minutes after heartbeat, 2 minutes after activity
57
- // With fallback: activity age = 2 minutes < dead threshold (5 minutes) -> should be warn/stale, not dead
58
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
59
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
60
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
61
-
62
- // Should NOT have any dead notifications because lastActivityAt is recent
63
- assert.equal(notifications.length, 0, "Should NOT mark task dead when lastActivityAt is recent (within dead threshold)");
64
- assert.equal(deadletters, 0, "Should NOT trigger deadletter when lastActivityAt is recent");
65
-
66
- watcher.dispose();
67
- cache.dispose();
68
- } finally {
69
- fs.rmSync(cwd, { recursive: true, force: true });
70
- }
71
- });
72
-
73
- test("HeartbeatWatcher marks task dead when BOTH heartbeat and lastActivityAt are stale", () => {
74
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-both-stale-"));
75
- try {
76
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
77
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
78
- const manifest = updateRunStatus(created.manifest, "running", "running");
79
-
80
- // Create task with BOTH stale heartbeat AND stale lastActivityAt
81
- // Heartbeat is from Jan 1, 2026 00:00:00 (10 minutes old)
82
- // lastActivityAt is also from Jan 1, 2026 00:00:00 (also 10 minutes old - beyond dead threshold)
83
- const tasksWithHeartbeat = created.tasks.map((task) => ({
84
- ...task,
85
- status: "running",
86
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
87
- agentProgress: {
88
- lastActivityAt: "2026-01-01T00:00:00.000Z", // 10 minutes old - beyond dead threshold
89
- currentTool: "done",
90
- toolCount: 5,
91
- tokens: 1000,
92
- turns: 2
93
- }
94
- }));
95
-
96
- saveRunTasks(manifest, tasksWithHeartbeat);
97
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
98
- const notifications = [];
99
- let deadletters = 0;
100
- const watcher = new HeartbeatWatcher({
101
- cwd,
102
- manifestCache: cache,
103
- registry: createMetricRegistry(),
104
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
105
- deadletterTickThreshold: 3,
106
- onDeadletterTrigger: () => { deadletters += 1; }
107
- });
108
-
109
- // Simulate time at 00:10:00 - both heartbeat and activity are 10 minutes old
110
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
111
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
112
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
113
-
114
- // SHOULD have dead notifications because BOTH are stale (> 5 minutes)
115
- assert.ok(notifications.length > 0, "Should mark task dead when BOTH heartbeat and lastActivityAt are stale");
116
- assert.ok(deadletters > 0, "Should trigger deadletter when BOTH are stale");
117
-
118
- watcher.dispose();
119
- cache.dispose();
120
- } finally {
121
- fs.rmSync(cwd, { recursive: true, force: true });
122
- }
123
- });
124
-
125
- test("HeartbeatWatcher without lastActivityAt still marks stale heartbeat as dead", () => {
126
- const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-no-activity-"));
127
- try {
128
- fs.writeFileSync(path.join(cwd, "package.json"), "{}", "utf-8");
129
- const created = createRunManifest({ cwd, team, workflow, goal: "hb" });
130
- const manifest = updateRunStatus(created.manifest, "running", "running");
131
-
132
- // Create task with stale heartbeat but NO lastActivityAt
133
- const tasksWithHeartbeat = created.tasks.map((task) => ({
134
- ...task,
135
- status: "running",
136
- heartbeat: { workerId: task.id, lastSeenAt: "2026-01-01T00:00:00.000Z", alive: true },
137
- // No agentProgress at all
138
- }));
139
-
140
- saveRunTasks(manifest, tasksWithHeartbeat);
141
- const cache = createManifestCache(cwd, { watch: false, debounceMs: 0 });
142
- const notifications = [];
143
- let deadletters = 0;
144
- const watcher = new HeartbeatWatcher({
145
- cwd,
146
- manifestCache: cache,
147
- registry: createMetricRegistry(),
148
- router: { enqueue: (n) => { notifications.push(n.id ?? ""); return true; } },
149
- deadletterTickThreshold: 3,
150
- onDeadletterTrigger: () => { deadletters += 1; }
151
- });
152
-
153
- // Simulate time at 00:10:00 - heartbeat is 10 minutes old
154
- watcher.tick(Date.parse("2026-01-01T00:10:00.000Z"));
155
- watcher.tick(Date.parse("2026-01-01T00:10:05.000Z"));
156
- watcher.tick(Date.parse("2026-01-01T00:10:10.000Z"));
157
-
158
- // SHOULD have dead notifications because heartbeat is stale and no fallback
159
- assert.ok(notifications.length > 0, "Should mark task dead when heartbeat stale and no lastActivityAt");
160
- assert.ok(deadletters > 0, "Should trigger deadletter when no fallback available");
161
-
162
- watcher.dispose();
163
- cache.dispose();
164
- } finally {
165
- fs.rmSync(cwd, { recursive: true, force: true });
166
- }
167
- });
package/test-tp.mjs DELETED
@@ -1,12 +0,0 @@
1
- import { formatToolProgress, formatCurrentToolLine } from "./src/runtime/tool-progress.ts";
2
-
3
- const progress = {
4
- recentTools: [{ tool: "bash", args: "ls", endedAt: "2024-01-01T00:00:00.000Z" }],
5
- toolCount: 1,
6
- activityState: "active"
7
- };
8
-
9
- const display = formatToolProgress(progress);
10
- console.log("currentTool:", display.currentTool);
11
- console.log("toolCount:", display.toolCount);
12
- console.log("TEST PASSED if no errors above");