pi-teams 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -45,22 +45,59 @@ You don't need to learn complex code commands. Just talk to Pi in plain English!
45
45
 
46
46
  ---
47
47
 
48
- ## 🪟 Requirement: tmux
48
+ ## 🛠 Available Tools
49
49
 
50
- To show multiple agents on one screen, **pi-teams** requires `tmux` (a terminal multiplexer).
50
+ Pi automatically uses these tools when you give instructions like the examples above.
51
51
 
52
- ### 1. Install tmux
52
+ ### Team Management
53
+ - `team_create`: Start a new team.
54
+ - `team_delete`: Delete a team and its data.
55
+ - `read_config`: Get details about the team and its members.
56
+
57
+ ### Teammates
58
+ - `spawn_teammate`: Launch a new agent into a `tmux` pane with a role and instructions.
59
+ - `check_teammate`: See if a teammate is still running or has unread messages.
60
+ - `force_kill_teammate`: Stop a teammate and remove them from the team.
61
+ - `process_shutdown_approved`: Orderly shutdown for a finished teammate.
62
+
63
+ ### Task Management
64
+ - `task_create`: Create a new task.
65
+ - `task_list`: List all tasks and their current status.
66
+ - `task_get`: Get full details of a specific task.
67
+ - `task_update`: Update a task's status or owner.
68
+
69
+ ### Messaging
70
+ - `send_message`: Send a message to a teammate or lead.
71
+ - `read_inbox`: Read incoming messages for an agent.
72
+
73
+ ---
74
+
75
+ ## 🤖 Automated Behavior
76
+
77
+ - **Initial Greeting**: When a teammate is spawned, they will automatically send a message saying they've started and are checking their inbox.
78
+ - **Idle Polling**: Teammates check for new messages every 30 seconds if they are idle.
79
+ - **Context Injection**: Each teammate is given a custom system prompt that defines their role and instructions for the team environment.
80
+
81
+ ---
82
+
83
+ ## 🪟 Requirements: tmux or Zellij
84
+
85
+ To show multiple agents on one screen, **pi-teams** requires a terminal multiplexer. It supports both **tmux** and **Zellij**.
86
+
87
+ ### Option 1: tmux (Recommended)
88
+
89
+ #### 1. Install tmux
53
90
  - **macOS**: `brew install tmux`
54
91
  - **Linux**: `sudo apt install tmux`
55
92
 
56
- ### 2. How to run it
93
+ #### 2. How to run it
57
94
  Before you start a team, you **must** be inside a tmux session. Simply type:
58
95
  ```bash
59
96
  tmux
60
97
  ```
61
98
  Then start `pi` inside that window.
62
99
 
63
- ### 3. Navigating Panes (Vanilla tmux)
100
+ #### 3. Navigating Panes (Vanilla tmux)
64
101
  When your screen splits into multiple agents, you use "Prefix" commands to move around. By default, the prefix is **`Ctrl+b`**.
65
102
 
66
103
  - **Switch to next agent**: Press `Ctrl+b` then `o`.
@@ -72,6 +109,13 @@ When your screen splits into multiple agents, you use "Prefix" commands to move
72
109
 
73
110
  *Note: You can greatly improve this experience by enabling "mouse mode" in your `~/.tmux.conf` file.*
74
111
 
112
+ ### Option 2: Zellij
113
+
114
+ If you prefer **Zellij**, simply start `pi` inside a Zellij session. **pi-teams** will detect it via the `ZELLIJ` environment variable and use `zellij run` to spawn teammates in new panes.
115
+
116
+ - **Switching Panes**: Use `Alt` + `Arrow Keys` (default) to move between agent panes.
117
+ - **Closing Panes**: Use `Ctrl+t` then `p` then `x` (or your configured shortcut) to close a teammate pane if needed, though `force_kill_teammate` is recommended.
118
+
75
119
  ---
76
120
 
77
121
  ## 📜 Credits & Attribution
@@ -6,7 +6,7 @@ import * as teams from "../src/utils/teams";
6
6
  import * as tasks from "../src/utils/tasks";
7
7
  import * as messaging from "../src/utils/messaging";
8
8
  import { Member } from "../src/utils/models";
9
- import { execSync } from "node:child_process";
9
+ import { execSync, spawnSync } from "node:child_process";
10
10
  import path from "node:path";
11
11
  import fs from "node:fs";
12
12
 
@@ -18,13 +18,17 @@ export default function (pi: ExtensionAPI) {
18
18
  pi.on("session_start", async (_event, ctx) => {
19
19
  paths.ensureDirs();
20
20
  if (isTeammate) {
21
+ if (teamName) {
22
+ const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
23
+ fs.writeFileSync(pidFile, process.pid.toString());
24
+ }
21
25
  ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
22
26
  // Use a shorter, more prominent status at the beginning if possible
23
27
  ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
24
28
 
25
29
  // Also set the tmux pane title for better visibility
26
30
  try {
27
- execSync(`tmux select-pane -T "${agentName}"`);
31
+ spawnSync("tmux", ["select-pane", "-T", agentName]);
28
32
  } catch (e) {
29
33
  // ignore
30
34
  }
@@ -87,13 +91,16 @@ export default function (pi: ExtensionAPI) {
87
91
  cwd: Type.String(),
88
92
  }),
89
93
  async execute(toolCallId, params, signal, onUpdate, ctx) {
90
- if (!teams.teamExists(params.team_name)) {
94
+ const safeName = paths.sanitizeName(params.name);
95
+ const safeTeamName = paths.sanitizeName(params.team_name);
96
+
97
+ if (!teams.teamExists(safeTeamName)) {
91
98
  throw new Error(`Team ${params.team_name} does not exist`);
92
99
  }
93
100
 
94
101
  const member: Member = {
95
- agentId: `${params.name}@${params.team_name}`,
96
- name: params.name,
102
+ agentId: `${safeName}@${safeTeamName}`,
103
+ name: safeName,
97
104
  agentType: "teammate",
98
105
  model: "sonnet",
99
106
  joinedAt: Date.now(),
@@ -104,19 +111,41 @@ export default function (pi: ExtensionAPI) {
104
111
  color: "blue",
105
112
  };
106
113
 
107
- await teams.addMember(params.team_name, member);
108
- await messaging.sendPlainMessage(params.team_name, "team-lead", params.name, params.prompt, "Initial prompt");
114
+ await teams.addMember(safeTeamName, member);
115
+ await messaging.sendPlainMessage(safeTeamName, "team-lead", safeName, params.prompt, "Initial prompt");
109
116
 
110
117
  const piBinary = process.argv[1] ? `node ${process.argv[1]}` : "pi"; // Assumed on path
111
- const cmd = `PI_TEAM_NAME=${params.team_name} PI_AGENT_NAME=${params.name} ${piBinary}`;
112
118
 
113
119
  let paneId = "";
114
120
  try {
115
- const tmuxCmd = `tmux split-window -h -dP -F "#{pane_id}" "cd ${params.cwd} && ${cmd}"`;
116
- paneId = execSync(tmuxCmd).toString().trim();
117
- execSync(`tmux select-layout even-horizontal`);
121
+ if (process.env.ZELLIJ && !process.env.TMUX) {
122
+ const zellijArgs = [
123
+ "run",
124
+ "--name", safeName,
125
+ "--cwd", params.cwd,
126
+ "--close-on-exit",
127
+ "--",
128
+ "env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
129
+ "sh", "-c", piBinary
130
+ ];
131
+ spawnSync("zellij", zellijArgs);
132
+ paneId = `zellij_${safeName}`;
133
+ } else {
134
+ const tmuxArgs = [
135
+ "split-window",
136
+ "-h", "-dP",
137
+ "-F", "#{pane_id}",
138
+ "-c", params.cwd,
139
+ "env", `PI_TEAM_NAME=${safeTeamName}`, `PI_AGENT_NAME=${safeName}`,
140
+ "sh", "-c", piBinary
141
+ ];
142
+ const result = spawnSync("tmux", tmuxArgs);
143
+ if (result.status !== 0) throw new Error(`tmux failed with status ${result.status}: ${result.stderr.toString()}`);
144
+ paneId = result.stdout.toString().trim();
145
+ spawnSync("tmux", ["select-layout", "even-horizontal"]);
146
+ }
118
147
  } catch (e) {
119
- throw new Error(`Failed to spawn tmux pane: ${e}`);
148
+ throw new Error(`Failed to spawn ${process.env.ZELLIJ && !process.env.TMUX ? "zellij" : "tmux"} pane: ${e}`);
120
149
  }
121
150
 
122
151
  // Update member with paneId
@@ -285,9 +314,23 @@ export default function (pi: ExtensionAPI) {
285
314
  const config = await teams.readConfig(params.team_name);
286
315
  const member = config.members.find(m => m.name === params.agent_name);
287
316
  if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
317
+
318
+ const pidFile = path.join(paths.teamDir(params.team_name), `${params.agent_name}.pid`);
319
+ if (fs.existsSync(pidFile)) {
320
+ try {
321
+ const pid = fs.readFileSync(pidFile, "utf-8").trim();
322
+ execSync(`kill -9 ${pid}`);
323
+ fs.unlinkSync(pidFile);
324
+ } catch (e) {
325
+ // ignore if process already dead
326
+ }
327
+ }
328
+
288
329
  if (member.tmuxPaneId) {
289
330
  try {
290
- execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
331
+ if (!member.tmuxPaneId.startsWith("zellij_")) {
332
+ execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
333
+ }
291
334
  } catch (e) {
292
335
  // ignore
293
336
  }
@@ -316,8 +359,13 @@ export default function (pi: ExtensionAPI) {
316
359
  let alive = false;
317
360
  if (member.tmuxPaneId) {
318
361
  try {
319
- execSync(`tmux has-session -t ${member.tmuxPaneId}`);
320
- alive = true;
362
+ if (member.tmuxPaneId.startsWith("zellij_")) {
363
+ // Assume alive if it's zellij for now
364
+ alive = true;
365
+ } else {
366
+ execSync(`tmux has-session -t ${member.tmuxPaneId}`);
367
+ alive = true;
368
+ }
321
369
  } catch (e) {
322
370
  alive = false;
323
371
  }
@@ -346,7 +394,11 @@ export default function (pi: ExtensionAPI) {
346
394
  if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
347
395
  if (member.tmuxPaneId) {
348
396
  try {
349
- execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
397
+ if (member.tmuxPaneId.startsWith("zellij_")) {
398
+ // zellij doesn't easily support closing a specific pane by name yet
399
+ } else {
400
+ execSync(`tmux kill-pane -t ${member.tmuxPaneId}`);
401
+ }
350
402
  } catch (e) {
351
403
  // ignore
352
404
  }
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": "github:burggraf/pi-teams",
6
6
  "author": "Mark Burggraf",
7
7
  "license": "MIT",
8
- "keywords": ["pi-package"],
8
+ "keywords": [
9
+ "pi-package"
10
+ ],
11
+ "scripts": {
12
+ "test": "vitest run"
13
+ },
9
14
  "main": "extensions/index.ts",
10
15
  "files": [
11
16
  "extensions",
@@ -23,7 +28,17 @@
23
28
  },
24
29
  "pi": {
25
30
  "image": "https://raw.githubusercontent.com/burggraf/pi-teams/main/pi-team-in-action.png",
26
- "extensions": ["extensions/index.ts"],
27
- "skills": ["skills"]
31
+ "extensions": [
32
+ "extensions/index.ts"
33
+ ],
34
+ "skills": [
35
+ "skills"
36
+ ]
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^25.3.0",
40
+ "ts-node": "^10.9.2",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.18"
28
43
  }
29
44
  }
package/skills/teams.md CHANGED
@@ -1,27 +1,36 @@
1
1
  ---
2
- description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or iTerm2.
2
+ description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or Zellij.
3
3
  ---
4
4
 
5
5
  # Agent Teams
6
6
 
7
- Coordinate multiple agents working on a project using shared task lists and messaging.
7
+ Coordinate multiple agents working on a project using shared task lists and messaging via **tmux** or **Zellij**.
8
8
 
9
9
  ## Workflow
10
10
 
11
11
  1. **Create a team**: Use `team_create(team_name="my-team")`.
12
12
  2. **Spawn teammates**: Use `spawn_teammate` to start additional agents. Give them specific roles and initial prompts.
13
- 3. **Manage tasks**: Use `task_create` to define work, and `task_update` to assign it to teammates.
13
+ 3. **Manage tasks**:
14
+ * `task_create`: Define work for the team.
15
+ * `task_list`: List all tasks to monitor progress or find available work.
16
+ * `task_get`: Get full details of a specific task by ID.
17
+ * `task_update`: Update a task's status (`pending`, `in_progress`, `completed`, `deleted`) or owner.
14
18
  4. **Communicate**: Use `send_message` to give instructions or receive updates. Teammates should use `read_inbox` to check for messages.
15
19
  5. **Monitor**: Use `check_teammate` to see if they are still running and if they have sent messages back.
20
+ 6. **Cleanup**:
21
+ * `force_kill_teammate`: Forcibly stop a teammate and remove them from the team.
22
+ * `process_shutdown_approved`: Orderly removal of a teammate after they've finished.
23
+ * `team_delete`: Remove a team and all its associated data.
16
24
 
17
25
  ## Teammate Instructions
18
26
 
19
27
  When you are spawned as a teammate:
20
28
  - Your status bar will show "Teammate: name @ team".
21
- - Always start by calling `read_inbox` to get your initial instructions.
29
+ - You will automatically start by calling `read_inbox` to get your initial instructions.
22
30
  - Regularly check `read_inbox` for updates from the lead.
23
31
  - Use `send_message` to "team-lead" to report progress or ask questions.
24
32
  - Update your assigned tasks using `task_update`.
33
+ - If you are idle for more than 30 seconds, you will automatically check your inbox for new messages.
25
34
 
26
35
  ## Best Practices for Teammates
27
36
 
@@ -29,3 +38,12 @@ When you are spawned as a teammate:
29
38
  - **Frequent Communication**: Send short summaries of your work back to `team-lead` frequently.
30
39
  - **Context Matters**: When you finish a task, send a message explaining your results and any new files you created.
31
40
  - **Independence**: If you get stuck, try to solve it yourself first, but don't hesitate to ask `team-lead` for clarification.
41
+ - **Orderly Shutdown**: When you've finished all your work and have no more instructions, notify the lead and wait for shutdown approval.
42
+
43
+ ## Best Practices for Team Leads
44
+
45
+ - **Clear Assignments**: Use `task_create` for all significant work items.
46
+ - **Contextual Prompts**: Provide enough context in `spawn_teammate` for the teammate to understand their specific role.
47
+ - **Task List Monitoring**: Regularly call `task_list` to see the status of all work.
48
+ - **Direct Feedback**: Use `send_message` to provide course corrections or new instructions to teammates.
49
+ - **Read Config**: Use `read_config` to see the full team roster and their current status.
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { withLock } from "./lock";
6
+
7
+ describe("withLock", () => {
8
+ const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now());
9
+ const lockPath = path.join(testDir, "test");
10
+ const lockFile = `${lockPath}.lock`;
11
+
12
+ beforeEach(() => {
13
+ if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks();
18
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
19
+ });
20
+
21
+ it("should successfully acquire and release the lock", async () => {
22
+ const fn = vi.fn().mockResolvedValue("result");
23
+ const result = await withLock(lockPath, fn);
24
+
25
+ expect(result).toBe("result");
26
+ expect(fn).toHaveBeenCalled();
27
+ expect(fs.existsSync(lockFile)).toBe(false);
28
+ });
29
+
30
+ it("should fail to acquire lock if already held", async () => {
31
+ // Manually create lock file
32
+ fs.writeFileSync(lockFile, "9999");
33
+
34
+ const fn = vi.fn().mockResolvedValue("result");
35
+
36
+ // We expect it to fail after timeout (50 * 100ms = 5s)
37
+ vi.useFakeTimers();
38
+
39
+ const promise = withLock(lockPath, fn);
40
+
41
+ // Fast-forward 6000ms to be safe
42
+ await vi.advanceTimersByTimeAsync(6000);
43
+
44
+ await expect(promise).rejects.toThrow("Could not acquire lock");
45
+ expect(fn).not.toHaveBeenCalled();
46
+
47
+ vi.useRealTimers();
48
+ });
49
+
50
+ it("should release lock even if function fails", async () => {
51
+ const fn = vi.fn().mockRejectedValue(new Error("failure"));
52
+
53
+ await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
54
+ expect(fs.existsSync(lockFile)).toBe(false);
55
+ });
56
+ });
package/src/utils/lock.ts CHANGED
@@ -3,9 +3,26 @@ import path from "node:path";
3
3
 
4
4
  export async function withLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
5
5
  const lockFile = `${lockPath}.lock`;
6
+ const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
7
+ const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
8
+
6
9
  let retries = 50;
7
10
  while (retries > 0) {
8
11
  try {
12
+ // Check if lock exists and is stale
13
+ if (fs.existsSync(lockFile)) {
14
+ const stats = fs.statSync(lockFile);
15
+ const age = Date.now() - stats.mtimeMs;
16
+ if (age > STALE_LOCK_TIMEOUT) {
17
+ // Attempt to remove stale lock
18
+ try {
19
+ fs.unlinkSync(lockFile);
20
+ } catch (e) {
21
+ // ignore, another process might have already removed it
22
+ }
23
+ }
24
+ }
25
+
9
26
  fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
10
27
  break;
11
28
  } catch (e) {
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { appendMessage, readInbox, sendPlainMessage } from "./messaging";
6
+ import * as paths from "./paths";
7
+
8
+ // Mock the paths to use a temporary directory
9
+ const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
10
+
11
+ describe("Messaging Utilities", () => {
12
+ beforeEach(() => {
13
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
14
+ fs.mkdirSync(testDir, { recursive: true });
15
+
16
+ // Override paths to use testDir
17
+ vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => {
18
+ return path.join(testDir, "inboxes", `${agentName}.json`);
19
+ });
20
+ vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks();
25
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
26
+ });
27
+
28
+ it("should append a message successfully", async () => {
29
+ const msg = { from: "sender", text: "hello", timestamp: "now", read: false };
30
+ await appendMessage("test-team", "receiver", msg);
31
+
32
+ const inbox = await readInbox("test-team", "receiver", false, false);
33
+ expect(inbox.length).toBe(1);
34
+ expect(inbox[0].text).toBe("hello");
35
+ });
36
+
37
+ it("should handle concurrent appends (Stress Test)", async () => {
38
+ const numMessages = 100;
39
+ const promises = [];
40
+ for (let i = 0; i < numMessages; i++) {
41
+ promises.push(sendPlainMessage("test-team", `sender-${i}`, "receiver", `msg-${i}`, `summary-${i}`));
42
+ }
43
+
44
+ await Promise.all(promises);
45
+
46
+ const inbox = await readInbox("test-team", "receiver", false, false);
47
+ expect(inbox.length).toBe(numMessages);
48
+
49
+ // Verify all messages are present
50
+ const texts = inbox.map(m => m.text).sort();
51
+ for (let i = 0; i < numMessages; i++) {
52
+ expect(texts).toContain(`msg-${i}`);
53
+ }
54
+ });
55
+
56
+ it("should mark messages as read", async () => {
57
+ await sendPlainMessage("test-team", "sender", "receiver", "msg1", "summary1");
58
+ await sendPlainMessage("test-team", "sender", "receiver", "msg2", "summary2");
59
+
60
+ // Read only unread messages
61
+ const unread = await readInbox("test-team", "receiver", true, true);
62
+ expect(unread.length).toBe(2);
63
+
64
+ // Now all should be read
65
+ const all = await readInbox("test-team", "receiver", false, false);
66
+ expect(all.length).toBe(2);
67
+ expect(all.every(m => m.read)).toBe(true);
68
+ });
69
+ });
@@ -12,16 +12,24 @@ export function ensureDirs() {
12
12
  if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
13
13
  }
14
14
 
15
+ export function sanitizeName(name: string): string {
16
+ // Allow only alphanumeric characters, hyphens, and underscores.
17
+ if (/[^a-zA-Z0-9_-]/.test(name)) {
18
+ throw new Error(`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
19
+ }
20
+ return name;
21
+ }
22
+
15
23
  export function teamDir(teamName: string) {
16
- return path.join(TEAMS_DIR, teamName);
24
+ return path.join(TEAMS_DIR, sanitizeName(teamName));
17
25
  }
18
26
 
19
27
  export function taskDir(teamName: string) {
20
- return path.join(TASKS_DIR, teamName);
28
+ return path.join(TASKS_DIR, sanitizeName(teamName));
21
29
  }
22
30
 
23
31
  export function inboxPath(teamName: string, agentName: string) {
24
- return path.join(teamDir(teamName), "inboxes", `${agentName}.json`);
32
+ return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`);
25
33
  }
26
34
 
27
35
  export function configPath(teamName: string) {
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import fs from "node:fs";
5
+ import { teamDir, inboxPath } from "./paths";
6
+
7
+ describe("Security Audit - Path Traversal (Prevention Check)", () => {
8
+ it("should throw an error for path traversal via teamName", () => {
9
+ const maliciousTeamName = "../../etc";
10
+ expect(() => teamDir(maliciousTeamName)).toThrow();
11
+ });
12
+
13
+ it("should throw an error for path traversal via agentName", () => {
14
+ const teamName = "audit-team";
15
+ const maliciousAgentName = "../../../.ssh/id_rsa";
16
+ expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
17
+ });
18
+
19
+ it("should throw an error for path traversal via taskId", () => {
20
+ const teamName = "audit-team";
21
+ const maliciousTaskId = "../../../etc/passwd";
22
+ // We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
23
+ // But since we already tested sanitizeName via other paths, this is just for completeness.
24
+ expect(() => sanitizeName(maliciousTaskId)).toThrow();
25
+ });
26
+ });
27
+
28
+ describe("Security Audit - Command Injection (Drafting)", () => {
29
+ it("should be vulnerable to command injection in spawn_teammate (via parameters)", () => {
30
+ const maliciousCwd = "; rm -rf / ;";
31
+ const name = "attacker";
32
+ const team_name = "audit-team";
33
+ const piBinary = "pi";
34
+ const cmd = `PI_TEAM_NAME=${team_name} PI_AGENT_NAME=${name} ${piBinary}`;
35
+
36
+ // Simulating what happens in spawn_teammate (extensions/index.ts)
37
+ const tmuxCmd = `tmux split-window -h -dP -F "#{pane_id}" "cd ${maliciousCwd} && ${cmd}"`;
38
+
39
+ // The command becomes: tmux split-window -h -dP -F "#{pane_id}" "cd ; rm -rf / ; && PI_TEAM_NAME=audit-team PI_AGENT_NAME=attacker pi"
40
+ expect(tmuxCmd).toContain("cd ; rm -rf / ; &&");
41
+ });
42
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { createTask, updateTask, readTask, listTasks } from "./tasks";
6
+ import * as paths from "./paths";
7
+ import * as teams from "./teams";
8
+
9
+ // Mock the paths to use a temporary directory
10
+ const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now());
11
+
12
+ describe("Tasks Utilities", () => {
13
+ beforeEach(() => {
14
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
15
+ fs.mkdirSync(testDir, { recursive: true });
16
+
17
+ // Override paths to use testDir
18
+ vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
19
+ vi.spyOn(paths, "configPath").mockReturnValue(path.join(testDir, "config.json"));
20
+
21
+ // Create a dummy team config
22
+ fs.writeFileSync(path.join(testDir, "config.json"), JSON.stringify({ name: "test-team" }));
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
28
+ });
29
+
30
+ it("should create a task successfully", async () => {
31
+ const task = await createTask("test-team", "Test Subject", "Test Description");
32
+ expect(task.id).toBe("1");
33
+ expect(task.subject).toBe("Test Subject");
34
+ expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
35
+ });
36
+
37
+ it("should update a task successfully", async () => {
38
+ await createTask("test-team", "Test Subject", "Test Description");
39
+ const updated = await updateTask("test-team", "1", { status: "in_progress" });
40
+ expect(updated.status).toBe("in_progress");
41
+
42
+ const taskData = JSON.parse(fs.readFileSync(path.join(testDir, "1.json"), "utf-8"));
43
+ expect(taskData.status).toBe("in_progress");
44
+ });
45
+
46
+ it("should list tasks", async () => {
47
+ await createTask("test-team", "Task 1", "Desc 1");
48
+ await createTask("test-team", "Task 2", "Desc 2");
49
+ const tasksList = await listTasks("test-team");
50
+ expect(tasksList.length).toBe(2);
51
+ expect(tasksList[0].id).toBe("1");
52
+ expect(tasksList[1].id).toBe("2");
53
+ });
54
+
55
+ it("should have consistent lock paths (Fixed BUG 2)", async () => {
56
+ // This test verifies that both updateTask and readTask now use the same lock path
57
+ // Both should now lock `${taskId}.json.lock`
58
+
59
+ await createTask("test-team", "Bug Test", "Testing lock consistency");
60
+ const taskId = "1";
61
+
62
+ const taskFile = path.join(testDir, `${taskId}.json`);
63
+ const commonLockFile = `${taskFile}.lock`;
64
+
65
+ // 1. Holding the common lock
66
+ fs.writeFileSync(commonLockFile, "9999");
67
+
68
+ // 2. Try updateTask, it should fail
69
+ vi.useFakeTimers();
70
+ const updatePromise = updateTask("test-team", taskId, { status: "in_progress" });
71
+ await vi.advanceTimersByTimeAsync(6000);
72
+ await expect(updatePromise).rejects.toThrow("Could not acquire lock");
73
+ vi.useRealTimers();
74
+
75
+ // 3. Try readTask, it should fail too
76
+ vi.useFakeTimers();
77
+ const readPromise = readTask("test-team", taskId);
78
+ await vi.advanceTimersByTimeAsync(6000);
79
+ await expect(readPromise).rejects.toThrow("Could not acquire lock");
80
+ vi.useRealTimers();
81
+
82
+ fs.unlinkSync(commonLockFile);
83
+ });
84
+ });
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { TaskFile } from "./models";
4
- import { taskDir } from "./paths";
4
+ import { taskDir, sanitizeName } from "./paths";
5
5
  import { teamExists } from "./teams";
6
6
  import { withLock } from "./lock";
7
7
 
@@ -48,10 +48,10 @@ export async function updateTask(
48
48
  updates: Partial<TaskFile>
49
49
  ): Promise<TaskFile> {
50
50
  const dir = taskDir(teamName);
51
- const lockPath = path.join(dir, taskId);
52
- const p = path.join(dir, `${taskId}.json`);
51
+ const safeTaskId = sanitizeName(taskId);
52
+ const p = path.join(dir, `${safeTaskId}.json`);
53
53
 
54
- return await withLock(lockPath, async () => {
54
+ return await withLock(p, async () => {
55
55
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
56
56
  const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
57
57
  const updated = { ...task, ...updates };
@@ -68,7 +68,8 @@ export async function updateTask(
68
68
 
69
69
  export async function readTask(teamName: string, taskId: string): Promise<TaskFile> {
70
70
  const dir = taskDir(teamName);
71
- const p = path.join(dir, `${taskId}.json`);
71
+ const safeTaskId = sanitizeName(taskId);
72
+ const p = path.join(dir, `${safeTaskId}.json`);
72
73
  if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
73
74
  return await withLock(p, async () => {
74
75
  return JSON.parse(fs.readFileSync(p, "utf-8"));