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.
- package/bot/cli.ts +33 -0
- package/bot/error-collector.ts +53 -3
- package/bot/index.ts +48 -1
- package/bot/slack-bot.ts +549 -296
- package/bot/spawn-as-user.ts +82 -0
- package/bot/task-user.ts +136 -0
- package/bot/taskInputFlow.spec.ts +87 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/bot/task-user.ts
ADDED
|
@@ -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
|
+
});
|