@ct-agents/worker 0.0.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.
@@ -0,0 +1,334 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
3
+ import { lstat, mkdir, open, readFile, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import type {
7
+ ExecResult,
8
+ Sandbox,
9
+ SandboxHandle,
10
+ } from '@ct-agents/protocol';
11
+
12
+ export type LocalProcessSandboxOptions = {
13
+ rootDir?: string;
14
+ nodeEnv?: string;
15
+ defaultMaxOutputBytes?: number;
16
+ };
17
+
18
+ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
19
+
20
+ export class LocalProcessSandbox implements Sandbox {
21
+ readonly kind = 'local' as const;
22
+ readonly isolated = false;
23
+ private readonly rootDir: string;
24
+ private readonly defaultMaxOutputBytes: number;
25
+
26
+ constructor(options: LocalProcessSandboxOptions = {}) {
27
+ const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV;
28
+ if (nodeEnv === 'production') {
29
+ throw new Error('LocalProcessSandbox 不能在 production 环境启用');
30
+ }
31
+ this.rootDir = options.rootDir ?? join(tmpdir(), 'ct-agents-local-sandbox');
32
+ this.defaultMaxOutputBytes = normalizePositiveInteger(options.defaultMaxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES);
33
+ }
34
+
35
+ async launch(input: { sessionId: string }): Promise<SandboxHandle> {
36
+ const workspaceRoot = resolve(this.rootDir, sanitizePathSegment(input.sessionId));
37
+ await mkdir(workspaceRoot, { recursive: true });
38
+ return new LocalProcessSandboxHandle(input.sessionId, workspaceRoot, this.defaultMaxOutputBytes);
39
+ }
40
+ }
41
+
42
+ class LocalProcessSandboxHandle implements SandboxHandle {
43
+ readonly isolated = false;
44
+
45
+ constructor(
46
+ readonly sessionId: string,
47
+ readonly workspaceRoot: string,
48
+ private readonly defaultMaxOutputBytes: number,
49
+ ) {}
50
+
51
+ exec(command: string, opts: { timeoutMs?: number; maxOutputBytes?: number } = {}): Promise<ExecResult> {
52
+ return new Promise((resolveResult) => {
53
+ const child = spawn(command, {
54
+ cwd: this.workspaceRoot,
55
+ detached: process.platform !== 'win32',
56
+ shell: true,
57
+ windowsHide: true,
58
+ });
59
+ const stdout = new BoundedOutputBuffer(normalizePositiveInteger(opts.maxOutputBytes, this.defaultMaxOutputBytes));
60
+ const stderr = new BoundedOutputBuffer(normalizePositiveInteger(opts.maxOutputBytes, this.defaultMaxOutputBytes));
61
+ let timedOut = false;
62
+ let settled = false;
63
+ let outputExceeded = false;
64
+ let terminationPromise: Promise<void> | undefined;
65
+ const stopForOutputLimit = () => {
66
+ if (outputExceeded) {
67
+ return;
68
+ }
69
+ outputExceeded = true;
70
+ terminationPromise = terminateProcessTree(child);
71
+ };
72
+ const timer = opts.timeoutMs
73
+ ? setTimeout(() => {
74
+ timedOut = true;
75
+ terminationPromise = terminateProcessTree(child);
76
+ }, opts.timeoutMs)
77
+ : undefined;
78
+
79
+ child.stdout.on('data', (chunk: Buffer) => {
80
+ if (stdout.append(chunk)) {
81
+ stopForOutputLimit();
82
+ }
83
+ });
84
+ child.stderr.on('data', (chunk: Buffer) => {
85
+ if (stderr.append(chunk)) {
86
+ stopForOutputLimit();
87
+ }
88
+ });
89
+ child.on('error', (error) => {
90
+ if (settled) {
91
+ return;
92
+ }
93
+ settled = true;
94
+ if (timer) {
95
+ clearTimeout(timer);
96
+ }
97
+ void (terminationPromise ?? terminateProcessTree(child)).then(() => {
98
+ const stdoutResult = stdout.result();
99
+ const stderrResult = stderr.result();
100
+ resolveResult({
101
+ stdout: stdoutResult.text,
102
+ stderr: stderrResult.text || error.message,
103
+ exitCode: 1,
104
+ timedOut,
105
+ ...(stdoutResult.truncated ? { stdoutTruncated: true, totalStdoutBytes: stdoutResult.totalBytes } : {}),
106
+ ...(stderrResult.truncated ? { stderrTruncated: true, totalStderrBytes: stderrResult.totalBytes } : {}),
107
+ });
108
+ });
109
+ });
110
+ child.on('close', (code, signal) => {
111
+ if (settled) {
112
+ return;
113
+ }
114
+ settled = true;
115
+ if (timer) {
116
+ clearTimeout(timer);
117
+ }
118
+ void (terminationPromise ?? Promise.resolve()).then(() => {
119
+ const stdoutResult = stdout.result();
120
+ const stderrResult = stderr.result();
121
+ const exitCode = code ?? (signal ? 1 : 0);
122
+ resolveResult({
123
+ stdout: stdoutResult.text,
124
+ stderr: stderrResult.text,
125
+ exitCode: (timedOut || outputExceeded) && exitCode === 0 ? 1 : exitCode,
126
+ timedOut,
127
+ ...(stdoutResult.truncated ? { stdoutTruncated: true, totalStdoutBytes: stdoutResult.totalBytes } : {}),
128
+ ...(stderrResult.truncated ? { stderrTruncated: true, totalStderrBytes: stderrResult.totalBytes } : {}),
129
+ });
130
+ });
131
+ });
132
+ });
133
+ }
134
+
135
+ async readFile(path: string, opts: { offset?: number; limit?: number; maxBytes?: number } = {}): Promise<string> {
136
+ const target = this.resolveWorkspacePath(path);
137
+ await this.assertExistingWorkspaceFile(target, path);
138
+ const maxBytes = normalizeOptionalPositiveInteger(opts.maxBytes);
139
+ const offset = normalizeOptionalNonNegativeInteger(opts.offset);
140
+ const limit = normalizeOptionalPositiveInteger(opts.limit);
141
+ if (maxBytes !== undefined || offset !== undefined || limit !== undefined) {
142
+ const start = offset ?? 0;
143
+ const bytesToRead = minDefined(limit, maxBytes);
144
+ if (bytesToRead === undefined) {
145
+ const content = await readFile(target);
146
+ return decodeCompleteUtf8(content.subarray(start));
147
+ }
148
+ return readFileRange(target, start, bytesToRead);
149
+ }
150
+ return readFile(target, 'utf8');
151
+ }
152
+
153
+ async writeFile(path: string, content: string): Promise<void> {
154
+ const target = this.resolveWorkspacePath(path);
155
+ await this.assertWritableWorkspacePath(target, path);
156
+ await writeFile(target, content, 'utf8');
157
+ }
158
+
159
+ async dispose(): Promise<void> {
160
+ await rm(this.workspaceRoot, { recursive: true, force: true });
161
+ }
162
+
163
+ private resolveWorkspacePath(path: string): string {
164
+ const target = resolve(this.workspaceRoot, path);
165
+ const relativePath = relative(this.workspaceRoot, target);
166
+ if (relativePath === '..' || relativePath.startsWith('..\\') || relativePath.startsWith('../') || isAbsolute(relativePath)) {
167
+ throw new Error(`路径越界:${path}`);
168
+ }
169
+ return target;
170
+ }
171
+
172
+ private async assertExistingWorkspaceFile(target: string, originalPath: string): Promise<void> {
173
+ const stat = await lstat(target);
174
+ if (!stat.isFile()) {
175
+ throw new Error(`路径不是普通文件:${originalPath}`);
176
+ }
177
+ await this.assertRealPathInsideWorkspace(target, originalPath);
178
+ }
179
+
180
+ private async assertWritableWorkspacePath(target: string, originalPath: string): Promise<void> {
181
+ await mkdir(dirname(target), { recursive: true });
182
+ await this.assertRealPathInsideWorkspace(dirname(target), originalPath);
183
+ try {
184
+ const stat = await lstat(target);
185
+ if (!stat.isFile()) {
186
+ throw new Error(`路径不是普通文件:${originalPath}`);
187
+ }
188
+ await this.assertRealPathInsideWorkspace(target, originalPath);
189
+ } catch (error) {
190
+ if (isNotFoundError(error)) {
191
+ return;
192
+ }
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ private async assertRealPathInsideWorkspace(target: string, originalPath: string): Promise<void> {
198
+ const [workspaceRealPath, targetRealPath] = await Promise.all([
199
+ realpath(this.workspaceRoot),
200
+ realpath(target),
201
+ ]);
202
+ const relativePath = relative(workspaceRealPath, targetRealPath);
203
+ if (relativePath === '..' || relativePath.startsWith('..\\') || relativePath.startsWith('../') || isAbsolute(relativePath)) {
204
+ throw new Error(`路径越界:${originalPath}`);
205
+ }
206
+ }
207
+ }
208
+
209
+ function sanitizePathSegment(value: string): string {
210
+ return value.replace(/[^a-zA-Z0-9._-]/g, '_');
211
+ }
212
+
213
+ class BoundedOutputBuffer {
214
+ private readonly chunks: Buffer[] = [];
215
+ private totalBytes = 0;
216
+ private bufferedBytes = 0;
217
+ private truncated = false;
218
+
219
+ constructor(private readonly maxBytes: number) {}
220
+
221
+ append(chunk: Buffer): boolean {
222
+ this.totalBytes += chunk.byteLength;
223
+ if (!this.truncated) {
224
+ const remainingBytes = this.maxBytes - this.bufferedBytes;
225
+ if (remainingBytes > 0) {
226
+ const stored = Buffer.from(chunk.subarray(0, remainingBytes));
227
+ this.chunks.push(stored);
228
+ this.bufferedBytes += stored.byteLength;
229
+ }
230
+ if (chunk.byteLength > remainingBytes) {
231
+ this.truncated = true;
232
+ }
233
+ }
234
+ return this.truncated;
235
+ }
236
+
237
+ result() {
238
+ return {
239
+ text: Buffer.concat(this.chunks).toString('utf8'),
240
+ truncated: this.truncated,
241
+ totalBytes: this.totalBytes,
242
+ };
243
+ }
244
+ }
245
+
246
+ function normalizePositiveInteger(value: number | undefined, fallback: number): number {
247
+ if (value === undefined || !Number.isFinite(value)) {
248
+ return fallback;
249
+ }
250
+ return Math.max(1, Math.trunc(value));
251
+ }
252
+
253
+ function normalizeOptionalPositiveInteger(value: number | undefined): number | undefined {
254
+ if (value === undefined || !Number.isFinite(value)) {
255
+ return undefined;
256
+ }
257
+ return Math.max(1, Math.trunc(value));
258
+ }
259
+
260
+ function normalizeOptionalNonNegativeInteger(value: number | undefined): number | undefined {
261
+ if (value === undefined || !Number.isFinite(value)) {
262
+ return undefined;
263
+ }
264
+ return Math.max(0, Math.trunc(value));
265
+ }
266
+
267
+ function minDefined(left: number | undefined, right: number | undefined): number | undefined {
268
+ if (left === undefined) {
269
+ return right;
270
+ }
271
+ if (right === undefined) {
272
+ return left;
273
+ }
274
+ return Math.min(left, right);
275
+ }
276
+
277
+ async function readFileRange(path: string, offset: number, maxBytes: number): Promise<string> {
278
+ if (maxBytes <= 0) {
279
+ return '';
280
+ }
281
+ const handle = await open(path, 'r');
282
+ try {
283
+ const buffer = Buffer.allocUnsafe(maxBytes);
284
+ const result = await handle.read(buffer, 0, maxBytes, offset);
285
+ return decodeCompleteUtf8(buffer.subarray(0, result.bytesRead));
286
+ } finally {
287
+ await handle.close();
288
+ }
289
+ }
290
+
291
+ function decodeCompleteUtf8(bytes: Uint8Array): string {
292
+ const decoder = new TextDecoder('utf-8', { fatal: true });
293
+ for (let end = bytes.byteLength; end >= 0; end -= 1) {
294
+ try {
295
+ return decoder.decode(bytes.subarray(0, end));
296
+ } catch {
297
+ // 回退到上一个完整 UTF-8 字符边界。
298
+ }
299
+ }
300
+ return '';
301
+ }
302
+
303
+ function isNotFoundError(error: unknown): boolean {
304
+ return error !== null
305
+ && typeof error === 'object'
306
+ && 'code' in error
307
+ && (error as { code?: unknown }).code === 'ENOENT';
308
+ }
309
+
310
+ function terminateProcessTree(child: ChildProcessWithoutNullStreams): Promise<void> {
311
+ const pid = child.pid;
312
+ if (!pid) {
313
+ child.kill();
314
+ return Promise.resolve();
315
+ }
316
+
317
+ if (process.platform === 'win32') {
318
+ return new Promise((resolveTaskkill) => {
319
+ const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
320
+ stdio: 'ignore',
321
+ windowsHide: true,
322
+ });
323
+ killer.once('error', () => resolveTaskkill());
324
+ killer.once('close', () => resolveTaskkill());
325
+ });
326
+ }
327
+
328
+ try {
329
+ process.kill(-pid, 'SIGKILL');
330
+ } catch {
331
+ child.kill('SIGKILL');
332
+ }
333
+ return Promise.resolve();
334
+ }
@@ -0,0 +1,141 @@
1
+ import type {
2
+ Sandbox,
3
+ SandboxHandle,
4
+ } from '@ct-agents/protocol';
5
+
6
+ export type SandboxManagerOptions = {
7
+ sandbox: Sandbox & { reapOrphanedContainers?: () => Promise<number> };
8
+ now?: () => number;
9
+ releaseTimeoutMs?: number;
10
+ };
11
+
12
+ type SandboxEntry = {
13
+ handlePromise: Promise<SandboxHandle>;
14
+ lastUsedAt: number;
15
+ ready: boolean;
16
+ disposing?: Promise<void>;
17
+ releaseWait?: Promise<void>;
18
+ };
19
+
20
+ export class SandboxManager {
21
+ private readonly entries = new Map<string, SandboxEntry>();
22
+ private readonly now: () => number;
23
+ private readonly releaseTimeoutMs: number;
24
+ private reapPromise: Promise<number> | undefined;
25
+ private closed = false;
26
+
27
+ constructor(private readonly options: SandboxManagerOptions) {
28
+ this.now = options.now ?? (() => Date.now());
29
+ this.releaseTimeoutMs = Math.max(1, Math.trunc(options.releaseTimeoutMs ?? 5000));
30
+ }
31
+
32
+ async forSession(input: { sessionId: string }): Promise<SandboxHandle> {
33
+ if (this.closed) {
34
+ throw new Error('SandboxManager 已关闭,不能再创建 sandbox');
35
+ }
36
+ if (this.options.sandbox.reapOrphanedContainers) {
37
+ await this.reapOnce();
38
+ }
39
+ const currentNow = this.now();
40
+ let entry = this.entries.get(input.sessionId);
41
+ if (entry?.disposing) {
42
+ await entry.disposing;
43
+ if (this.closed) {
44
+ throw new Error('SandboxManager 已关闭,不能再创建 sandbox');
45
+ }
46
+ entry = this.entries.get(input.sessionId);
47
+ }
48
+ if (!entry) {
49
+ const handlePromise = this.options.sandbox.launch({ sessionId: input.sessionId });
50
+ const newEntry: SandboxEntry = {
51
+ handlePromise,
52
+ lastUsedAt: currentNow,
53
+ ready: false,
54
+ };
55
+ entry = newEntry;
56
+ this.entries.set(input.sessionId, entry);
57
+ void handlePromise.then(
58
+ () => {
59
+ if (this.entries.get(input.sessionId) === newEntry) {
60
+ newEntry.ready = true;
61
+ }
62
+ },
63
+ () => {
64
+ if (this.entries.get(input.sessionId) === newEntry) {
65
+ this.entries.delete(input.sessionId);
66
+ }
67
+ },
68
+ );
69
+ }
70
+ entry.lastUsedAt = currentNow;
71
+ return entry.handlePromise;
72
+ }
73
+
74
+ async release(sessionId: string): Promise<void> {
75
+ const entry = this.entries.get(sessionId);
76
+ if (!entry) {
77
+ return;
78
+ }
79
+ if (!entry.disposing) {
80
+ entry.disposing = (async () => {
81
+ try {
82
+ const handle = await entry.handlePromise.catch(() => null);
83
+ if (!handle) {
84
+ return;
85
+ }
86
+ await this.disposeHandle(handle);
87
+ } finally {
88
+ if (this.entries.get(sessionId) === entry) {
89
+ this.entries.delete(sessionId);
90
+ }
91
+ }
92
+ })();
93
+ entry.releaseWait = this.waitForReleaseTimeout(entry.disposing);
94
+ }
95
+ await (entry.releaseWait ?? entry.disposing);
96
+ }
97
+
98
+ async sweepIdle(ttlMs: number): Promise<number> {
99
+ const threshold = this.now() - Math.max(0, Math.trunc(ttlMs));
100
+ const idleSessionIds = Array.from(this.entries.entries())
101
+ .filter(([, entry]) => !entry.disposing && entry.ready && entry.lastUsedAt <= threshold)
102
+ .map(([sessionId]) => sessionId);
103
+
104
+ for (const sessionId of idleSessionIds) {
105
+ await this.release(sessionId);
106
+ }
107
+
108
+ return idleSessionIds.length;
109
+ }
110
+
111
+ async disposeAll(): Promise<void> {
112
+ this.closed = true;
113
+ while (this.entries.size > 0) {
114
+ const sessionIds = Array.from(this.entries.keys());
115
+ await Promise.all(sessionIds.map((sessionId) => this.release(sessionId).catch(() => undefined)));
116
+ if (sessionIds.every((sessionId) => this.entries.has(sessionId))) {
117
+ break;
118
+ }
119
+ }
120
+ }
121
+
122
+ private waitForReleaseTimeout(disposing: Promise<void>) {
123
+ return Promise.race([
124
+ disposing,
125
+ new Promise<void>((resolve) => {
126
+ setTimeout(resolve, this.releaseTimeoutMs);
127
+ }),
128
+ ]);
129
+ }
130
+
131
+ private async disposeHandle(handle: SandboxHandle) {
132
+ await handle.dispose().catch(() => undefined);
133
+ }
134
+
135
+ private async reapOnce() {
136
+ if (!this.reapPromise) {
137
+ this.reapPromise = this.options.sandbox.reapOrphanedContainers?.() ?? Promise.resolve(0);
138
+ }
139
+ await this.reapPromise;
140
+ }
141
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ import type {
3
+ ResourceDefinition,
4
+ Sandbox,
5
+ } from '@ct-agents/protocol';
6
+
7
+ export type SandboxLifecycle = 'session' | 'work-item';
8
+
9
+ export type SandboxResourceOptions = {
10
+ allowedCommands?: string[];
11
+ deniedCommands?: string[];
12
+ requireApproval?: boolean;
13
+ timeoutMs?: number;
14
+ sandboxLifecycle?: SandboxLifecycle;
15
+ workspaceRoot?: string;
16
+ readPaths?: string[];
17
+ writePaths?: string[];
18
+ limits?: {
19
+ cpu?: number;
20
+ memoryMb?: number;
21
+ processes?: number;
22
+ };
23
+ };
24
+
25
+ export const sandboxResourceOptionsSchema = z.object({
26
+ allowedCommands: z.array(z.string().trim().min(1)).optional(),
27
+ deniedCommands: z.array(z.string().trim().min(1)).optional(),
28
+ requireApproval: z.boolean().optional(),
29
+ timeoutMs: z.number().int().positive().optional(),
30
+ sandboxLifecycle: z.enum(['session', 'work-item']).optional(),
31
+ workspaceRoot: z.string().trim().min(1).optional(),
32
+ readPaths: z.array(z.string().trim().min(1)).optional(),
33
+ writePaths: z.array(z.string().trim().min(1)).optional(),
34
+ limits: z.object({
35
+ cpu: z.number().positive().optional(),
36
+ memoryMb: z.number().int().positive().optional(),
37
+ processes: z.number().int().positive().optional(),
38
+ }).strict().optional(),
39
+ }).strict();
40
+
41
+ export function createSandboxResourceDefinition(): ResourceDefinition<Sandbox, SandboxResourceOptions> {
42
+ return {
43
+ id: 'sandbox',
44
+ title: 'Sandbox',
45
+ description: 'worker 侧 sandbox resource 配置声明。实际 sandbox 句柄由 worker 本地配置和 SandboxManager 注入。',
46
+ implementations: [
47
+ {
48
+ id: 'local-process',
49
+ title: 'Local process',
50
+ description: '本地开发用 sandbox 声明;生产应替换为隔离实现。',
51
+ optionsSchema: sandboxResourceOptionsSchema,
52
+ optionsJsonSchema: z.toJSONSchema(sandboxResourceOptionsSchema),
53
+ factory: () => {
54
+ throw new Error('SANDBOX_RESOURCE_DEFINITION_ONLY');
55
+ },
56
+ },
57
+ ],
58
+ };
59
+ }
@@ -0,0 +1,30 @@
1
+ import type {
2
+ Database,
3
+ MemoryResource,
4
+ ResourceSlots,
5
+ SandboxHandle,
6
+ SkillResource,
7
+ ToolWorkItem,
8
+ } from '@ct-agents/protocol';
9
+ import type { ResolveResources } from '../index.js';
10
+
11
+ export type CreateSandboxResolveResourcesInput = {
12
+ manager: {
13
+ forSession(input: { sessionId: string }): Promise<SandboxHandle>;
14
+ };
15
+ database?: Database;
16
+ skills?: SkillResource;
17
+ memory?: MemoryResource;
18
+ };
19
+
20
+ export function createSandboxResolveResources(input: CreateSandboxResolveResourcesInput): ResolveResources {
21
+ return async (item: ToolWorkItem): Promise<ResourceSlots> => {
22
+ const sandbox = await input.manager.forSession({ sessionId: item.sessionId });
23
+ return {
24
+ sandbox,
25
+ ...(input.database ? { database: input.database } : {}),
26
+ ...(input.skills ? { skills: input.skills } : {}),
27
+ ...(input.memory ? { memory: input.memory } : {}),
28
+ };
29
+ };
30
+ }