comfy-pr 1.4.2 → 1.5.0

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Spawn Claude Code CLI as a specific Linux user via sudo.
3
+ *
4
+ * Used with the Claude Agent SDK's `spawnClaudeCodeProcess` option
5
+ * to run agent subprocesses as per-task non-root users.
6
+ */
7
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
8
+ import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk";
9
+
10
+ /**
11
+ * Create a spawnClaudeCodeProcess function that runs the CLI as a specific Linux user.
12
+ *
13
+ * The SDK passes: { command, args, cwd, env, signal }
14
+ * We wrap this in: sudo -n -u <username> <command> <args...>
15
+ */
16
+ export function createUserSpawner(
17
+ username: string,
18
+ taskHome: string,
19
+ ): (options: SpawnOptions) => SpawnedProcess {
20
+ return (options: SpawnOptions): SpawnedProcess => {
21
+ const { command, args, cwd, env, signal } = options;
22
+
23
+ const childEnv: NodeJS.ProcessEnv = {
24
+ ...process.env,
25
+ ...env,
26
+ HOME: taskHome,
27
+ USER: username,
28
+ LOGNAME: username,
29
+ };
30
+ // Ensure PATH includes bun/node/claude locations
31
+ childEnv.PATH = `/root/.bun/bin:/root/.local/bin:/root/.nvm/versions/node/v25.2.1/bin:${childEnv.PATH || "/usr/local/bin:/usr/bin:/bin"}`;
32
+
33
+ // Resolve command to full path (sudo resets PATH)
34
+ const resolvedCommand =
35
+ command === "bun"
36
+ ? "/root/.bun/bin/bun"
37
+ : command === "node"
38
+ ? "/root/.nvm/versions/node/v25.2.1/bin/node"
39
+ : command === "claude"
40
+ ? "/root/.local/bin/claude"
41
+ : command;
42
+
43
+ // Use sudo to run as the task user
44
+ const sudoArgs = ["-n", "-u", username, "--preserve-env", resolvedCommand, ...args];
45
+
46
+ const proc = spawn("sudo", sudoArgs, {
47
+ cwd,
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ env: childEnv,
50
+ }) as unknown as ChildProcessWithoutNullStreams;
51
+
52
+ // Wire up abort signal
53
+ if (signal) {
54
+ signal.addEventListener("abort", () => {
55
+ proc.kill("SIGTERM");
56
+ });
57
+ }
58
+
59
+ return {
60
+ stdin: proc.stdin,
61
+ stdout: proc.stdout,
62
+ get killed() {
63
+ return proc.killed;
64
+ },
65
+ get exitCode() {
66
+ return proc.exitCode;
67
+ },
68
+ kill(signal: NodeJS.Signals) {
69
+ return proc.kill(signal);
70
+ },
71
+ on(event, listener) {
72
+ proc.on(event, listener as never);
73
+ },
74
+ once(event, listener) {
75
+ proc.once(event, listener as never);
76
+ },
77
+ off(event, listener) {
78
+ proc.off(event, listener as never);
79
+ },
80
+ };
81
+ };
82
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Task User Management
3
+ *
4
+ * Creates and manages per-task Linux users for agent isolation.
5
+ * Each agent task runs as its own Linux user to prevent cross-task interference.
6
+ */
7
+ import { $ } from "bun";
8
+
9
+ const TASK_USER_PREFIX = "task-";
10
+ const TASK_USER_GROUP = "comfy-tasks";
11
+ const TASK_ACTIVITY_FILE = ".last-activity";
12
+
13
+ export interface TaskUser {
14
+ username: string;
15
+ homeDir: string;
16
+ }
17
+
18
+ function workspaceIdToTaskSuffix(workspaceId: string): string {
19
+ return workspaceId.replace(".", "-").slice(0, 24);
20
+ }
21
+
22
+ function taskHomeDir(username: string): string {
23
+ return `/tmp/${username}`;
24
+ }
25
+
26
+ async function writeTaskActivity(username: string): Promise<void> {
27
+ const homeDir = taskHomeDir(username);
28
+ const activityPath = `${homeDir}/${TASK_ACTIVITY_FILE}`;
29
+ await Bun.write(activityPath, new Date().toISOString());
30
+ await $`chown ${username}:${TASK_USER_GROUP} ${activityPath}`.quiet().catch(() => {});
31
+ }
32
+
33
+ /** Ensure the shared group exists */
34
+ async function ensureGroup(): Promise<void> {
35
+ try {
36
+ await $`getent group ${TASK_USER_GROUP}`.quiet();
37
+ } catch {
38
+ await $`groupadd --system ${TASK_USER_GROUP}`.quiet();
39
+ }
40
+ }
41
+
42
+ /** Create a temporary Linux user for a task */
43
+ export async function createTaskUser(workspaceId: string): Promise<TaskUser> {
44
+ await ensureGroup();
45
+
46
+ const username = `${TASK_USER_PREFIX}${workspaceIdToTaskSuffix(workspaceId)}`;
47
+ const homeDir = taskHomeDir(username);
48
+
49
+ // Check if user already exists (task resume)
50
+ try {
51
+ await $`id ${username}`.quiet();
52
+ // User exists, just ensure home dir
53
+ await $`mkdir -p ${homeDir}/.claude`.quiet();
54
+ await $`chown -R ${username}:${TASK_USER_GROUP} ${homeDir}`.quiet();
55
+ await writeTaskActivity(username);
56
+ return { username, homeDir };
57
+ } catch {
58
+ // User doesn't exist, create it
59
+ }
60
+
61
+ await $`useradd --system --no-create-home --gid ${TASK_USER_GROUP} --shell /bin/sh ${username}`.quiet();
62
+ await $`mkdir -p ${homeDir}/.claude`.quiet();
63
+ await $`chown -R ${username}:${TASK_USER_GROUP} ${homeDir}`.quiet();
64
+ await writeTaskActivity(username);
65
+
66
+ return { username, homeDir };
67
+ }
68
+
69
+ /** Set up workspace directory ownership for the task user */
70
+ export async function prepareTaskWorkspace(username: string, workDir: string): Promise<void> {
71
+ await $`mkdir -p ${workDir}`.quiet();
72
+ await $`chown -R ${username}:${TASK_USER_GROUP} ${workDir}`.quiet();
73
+ }
74
+
75
+ /** Delete a task user and clean up */
76
+ export async function deleteTaskUser(username: string): Promise<void> {
77
+ if (!username.startsWith(TASK_USER_PREFIX)) return; // safety guard
78
+ await $`userdel ${username}`.quiet().catch(() => {});
79
+ await $`rm -rf /tmp/${username}`.quiet().catch(() => {});
80
+ }
81
+
82
+ /** Record that a task received new activity */
83
+ export async function touchTaskUserActivity(workspaceId: string): Promise<void> {
84
+ const username = `${TASK_USER_PREFIX}${workspaceIdToTaskSuffix(workspaceId)}`;
85
+ const homeDir = taskHomeDir(username);
86
+ await $`mkdir -p ${homeDir}`.quiet();
87
+ await writeTaskActivity(username);
88
+ }
89
+
90
+ /** List all task-* users */
91
+ export async function listTaskUsers(): Promise<string[]> {
92
+ try {
93
+ const output = await $`getent passwd`.text();
94
+ return output
95
+ .split("\n")
96
+ .filter((line) => line.startsWith(TASK_USER_PREFIX))
97
+ .map((line) => line.split(":")[0]);
98
+ } catch {
99
+ return [];
100
+ }
101
+ }
102
+
103
+ /** Clean up stale task users not in the active set */
104
+ export async function cleanupStaleTaskUsers(activeWorkspaceIds: Set<string>): Promise<string[]> {
105
+ const users = await listTaskUsers();
106
+ const cleaned: string[] = [];
107
+ const activeTaskSuffixes = new Set([...activeWorkspaceIds].map(workspaceIdToTaskSuffix));
108
+
109
+ for (const username of users) {
110
+ const taskSuffix = username.slice(TASK_USER_PREFIX.length);
111
+
112
+ // Check if this task is still active
113
+ const isActive = activeTaskSuffixes.has(taskSuffix);
114
+ if (isActive) continue;
115
+
116
+ // Skip if the task has seen activity in the last 24h.
117
+ const homeDir = taskHomeDir(username);
118
+ const activityPath = `${homeDir}/${TASK_ACTIVITY_FILE}`;
119
+ try {
120
+ const stat = await Bun.file(activityPath).stat();
121
+ if (Date.now() - stat.mtimeMs < 24 * 60 * 60 * 1000) continue;
122
+ } catch {
123
+ try {
124
+ const stat = await Bun.file(`${homeDir}/.claude`).stat();
125
+ if (Date.now() - stat.mtimeMs < 24 * 60 * 60 * 1000) continue;
126
+ } catch {
127
+ // No activity file or fallback dir; safe to clean.
128
+ }
129
+ }
130
+
131
+ await deleteTaskUser(username);
132
+ cleaned.push(username);
133
+ }
134
+
135
+ return cleaned;
136
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ /**
4
+ * Verifies the TaskInputFlow drain pattern used by slack-bot.ts to inject
5
+ * follow-up Slack messages into a running Claude Agent SDK session.
6
+ *
7
+ * The bot wraps a TransformStream<string, string> per workspace; writers
8
+ * push new user messages, the SDK reader yields them as SDKUserMessage.
9
+ *
10
+ * If the drain logic ever regresses (e.g., reader gets dropped, debouncer
11
+ * eats messages), the live "follow-up" feature breaks silently — we have
12
+ * no production telemetry for it. These tests guard the contract.
13
+ */
14
+ describe("TaskInputFlow drain contract", () => {
15
+ test("buffered writes are read back in order, single-reader", async () => {
16
+ // TransformStream has highWaterMark=1, so writer.write() awaits the
17
+ // reader for the second chunk. Writers fire-and-forget here to mirror
18
+ // how slack-bot.ts pushes follow-ups without blocking on the agent.
19
+ const flow = new TransformStream<string, string>();
20
+ const writer = flow.writable.getWriter();
21
+ const reader = flow.readable.getReader();
22
+
23
+ void writer.write("first follow-up");
24
+ void writer.write("second follow-up");
25
+
26
+ const a = await reader.read();
27
+ const b = await reader.read();
28
+ expect(a).toEqual({ done: false, value: "first follow-up" });
29
+ expect(b).toEqual({ done: false, value: "second follow-up" });
30
+
31
+ writer.releaseLock();
32
+ reader.releaseLock();
33
+ });
34
+
35
+ test("close() ends the reader loop with done=true", async () => {
36
+ const flow = new TransformStream<string, string>();
37
+ const writer = flow.writable.getWriter();
38
+ const reader = flow.readable.getReader();
39
+
40
+ void writer.write("only message");
41
+ void writer.close();
42
+
43
+ const a = await reader.read();
44
+ const b = await reader.read();
45
+ expect(a).toEqual({ done: false, value: "only message" });
46
+ expect(b.done).toBe(true);
47
+ });
48
+
49
+ test("multiple released-and-reacquired writers preserve order", async () => {
50
+ // Mirrors the actual usage pattern in slack-bot.ts: each follow-up goes
51
+ // through a fresh .getWriter() / .write() / .releaseLock() cycle, with
52
+ // a concurrent reader draining into the SDK agent.
53
+ const flow = new TransformStream<string, string>();
54
+ const reader = flow.readable.getReader();
55
+
56
+ const writeAll = (async () => {
57
+ for (const text of ["one", "two", "three"]) {
58
+ const w = flow.writable.getWriter();
59
+ await w.write(text);
60
+ w.releaseLock();
61
+ }
62
+ })();
63
+
64
+ expect((await reader.read()).value).toBe("one");
65
+ expect((await reader.read()).value).toBe("two");
66
+ expect((await reader.read()).value).toBe("three");
67
+
68
+ await writeAll;
69
+ reader.releaseLock();
70
+ });
71
+
72
+ test("reader started before any write still receives values", async () => {
73
+ const flow = new TransformStream<string, string>();
74
+ const reader = flow.readable.getReader();
75
+
76
+ // Kick off the read first; it should park until the write lands.
77
+ const readPromise = reader.read();
78
+
79
+ const w = flow.writable.getWriter();
80
+ await w.write("late message");
81
+ w.releaseLock();
82
+
83
+ const result = await readPromise;
84
+ expect(result).toEqual({ done: false, value: "late message" });
85
+ reader.releaseLock();
86
+ });
87
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comfy-pr",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Make PRs that publishes ComfyUI Custom Nodes to [ComfyUI Registry]( https://registry.comfy.org/ ).",
5
5
  "keywords": [],
6
6
  "license": "ISC",