@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.
- package/package.json +29 -0
- package/src/index.ts +1134 -0
- package/src/resources/database/index.ts +1 -0
- package/src/resources/database/postgres.ts +138 -0
- package/src/resources/index.ts +2 -0
- package/src/resources/platform-proxy.ts +169 -0
- package/src/sandbox/docker.ts +587 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/local-process.ts +334 -0
- package/src/sandbox/manager.ts +141 -0
- package/src/sandbox/resources-def.ts +59 -0
- package/src/sandbox/resources.ts +30 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
4
|
+
import type {
|
|
5
|
+
ExecResult,
|
|
6
|
+
Sandbox,
|
|
7
|
+
SandboxHandle,
|
|
8
|
+
} from '@ct-agents/protocol';
|
|
9
|
+
|
|
10
|
+
type DockerContainer = {
|
|
11
|
+
id?: string;
|
|
12
|
+
start(): Promise<unknown>;
|
|
13
|
+
exec(options: DockerExecCreateOptions): Promise<DockerExec>;
|
|
14
|
+
remove(options?: { force?: boolean; v?: boolean }): Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type DockerExec = {
|
|
18
|
+
start(options?: { hijack?: boolean; stdin?: boolean }): Promise<DockerReadableStream>;
|
|
19
|
+
inspect(): Promise<{ ExitCode?: number | null; Running?: boolean }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type DockerReadableStream = NodeJS.ReadableStream & {
|
|
23
|
+
destroy?: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type DockerClient = {
|
|
27
|
+
createContainer(options: DockerCreateContainerOptions): Promise<DockerContainer>;
|
|
28
|
+
listContainers?(options?: { all?: boolean; filters?: Record<string, string[]> }): Promise<Array<{ Id?: string; id?: string }>>;
|
|
29
|
+
getContainer?(id: string): Pick<DockerContainer, 'remove'>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type DockerExecCreateOptions = {
|
|
33
|
+
Cmd: string[];
|
|
34
|
+
AttachStdout: boolean;
|
|
35
|
+
AttachStderr: boolean;
|
|
36
|
+
Tty: boolean;
|
|
37
|
+
User?: string;
|
|
38
|
+
WorkingDir?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type DockerCreateContainerOptions = {
|
|
42
|
+
Image: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
User: string;
|
|
45
|
+
WorkingDir: string;
|
|
46
|
+
Cmd: string[];
|
|
47
|
+
Tty: boolean;
|
|
48
|
+
OpenStdin: boolean;
|
|
49
|
+
NetworkDisabled: boolean;
|
|
50
|
+
Labels: Record<string, string>;
|
|
51
|
+
HostConfig: {
|
|
52
|
+
Runtime?: string;
|
|
53
|
+
CapDrop: string[];
|
|
54
|
+
SecurityOpt: string[];
|
|
55
|
+
ReadonlyRootfs: boolean;
|
|
56
|
+
NetworkMode: 'none';
|
|
57
|
+
AutoRemove: boolean;
|
|
58
|
+
CpuQuota?: number;
|
|
59
|
+
Memory?: number;
|
|
60
|
+
PidsLimit?: number;
|
|
61
|
+
Tmpfs: Record<string, string>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type DockerSandboxRuntime = 'runsc' | 'runc';
|
|
66
|
+
|
|
67
|
+
export type DockerSandboxOptions = {
|
|
68
|
+
docker: DockerClient;
|
|
69
|
+
image: string;
|
|
70
|
+
runtime?: DockerSandboxRuntime;
|
|
71
|
+
ownerId?: string;
|
|
72
|
+
nodeEnv?: string;
|
|
73
|
+
user?: string;
|
|
74
|
+
workspaceDir?: string;
|
|
75
|
+
tmpSize?: string;
|
|
76
|
+
workspaceSize?: string;
|
|
77
|
+
cpuQuota?: number;
|
|
78
|
+
memoryBytes?: number;
|
|
79
|
+
pidsLimit?: number;
|
|
80
|
+
defaultMaxOutputBytes?: number;
|
|
81
|
+
defaultFileTimeoutMs?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CreateDockerSandboxOptions = Omit<DockerSandboxOptions, 'runtime'> & {
|
|
85
|
+
useGvisor?: boolean;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export class DockerSandbox implements Sandbox {
|
|
89
|
+
readonly kind = 'docker' as const;
|
|
90
|
+
readonly isolated: boolean;
|
|
91
|
+
private readonly runtime: DockerSandboxRuntime;
|
|
92
|
+
private readonly ownerId: string;
|
|
93
|
+
private readonly user: string;
|
|
94
|
+
private readonly workspaceDir: string;
|
|
95
|
+
private readonly tmpSize: string;
|
|
96
|
+
private readonly workspaceSize: string;
|
|
97
|
+
private readonly defaultMaxOutputBytes: number;
|
|
98
|
+
private readonly defaultFileTimeoutMs: number;
|
|
99
|
+
|
|
100
|
+
constructor(private readonly options: DockerSandboxOptions) {
|
|
101
|
+
this.runtime = options.runtime ?? 'runsc';
|
|
102
|
+
this.isolated = this.runtime === 'runsc';
|
|
103
|
+
this.ownerId = options.ownerId?.trim() || process.env.CT_AGENTS_SANDBOX_OWNER_ID?.trim() || `pid-${process.pid}`;
|
|
104
|
+
this.user = options.user ?? '1000:1000';
|
|
105
|
+
this.workspaceDir = options.workspaceDir ?? '/workspace';
|
|
106
|
+
this.tmpSize = options.tmpSize ?? '64m';
|
|
107
|
+
this.workspaceSize = options.workspaceSize ?? '256m';
|
|
108
|
+
this.defaultMaxOutputBytes = normalizePositiveInteger(options.defaultMaxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES);
|
|
109
|
+
this.defaultFileTimeoutMs = normalizePositiveInteger(options.defaultFileTimeoutMs, DEFAULT_FILE_TIMEOUT_MS);
|
|
110
|
+
|
|
111
|
+
if ((options.nodeEnv ?? process.env.NODE_ENV) === 'production' && !this.isolated) {
|
|
112
|
+
throw new Error('production 环境必须使用隔离 sandbox runtime');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async launch(input: { sessionId: string }): Promise<SandboxHandle> {
|
|
117
|
+
const safeSessionId = sanitizeDockerName(input.sessionId);
|
|
118
|
+
const container = await this.options.docker.createContainer({
|
|
119
|
+
Image: this.options.image,
|
|
120
|
+
name: `ct-agents-sandbox-${safeSessionId}`,
|
|
121
|
+
User: this.user,
|
|
122
|
+
WorkingDir: this.workspaceDir,
|
|
123
|
+
Cmd: ['sleep', 'infinity'],
|
|
124
|
+
Tty: false,
|
|
125
|
+
OpenStdin: false,
|
|
126
|
+
NetworkDisabled: true,
|
|
127
|
+
Labels: {
|
|
128
|
+
'ct-agents.sandbox': 'true',
|
|
129
|
+
'ct-agents.sandbox.owner': this.ownerId,
|
|
130
|
+
'ct-agents.session-id': safeSessionId,
|
|
131
|
+
},
|
|
132
|
+
HostConfig: {
|
|
133
|
+
...(this.runtime === 'runsc' ? { Runtime: 'runsc' } : {}),
|
|
134
|
+
CapDrop: ['ALL'],
|
|
135
|
+
SecurityOpt: ['no-new-privileges'],
|
|
136
|
+
ReadonlyRootfs: true,
|
|
137
|
+
NetworkMode: 'none',
|
|
138
|
+
AutoRemove: false,
|
|
139
|
+
CpuQuota: this.options.cpuQuota ?? DEFAULT_CPU_QUOTA,
|
|
140
|
+
Memory: this.options.memoryBytes ?? DEFAULT_MEMORY_BYTES,
|
|
141
|
+
PidsLimit: this.options.pidsLimit ?? DEFAULT_PIDS_LIMIT,
|
|
142
|
+
Tmpfs: {
|
|
143
|
+
'/tmp': `rw,noexec,nosuid,nodev,size=${this.tmpSize}`,
|
|
144
|
+
[this.workspaceDir]: `rw,nosuid,nodev,size=${this.workspaceSize}`,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
await container.start();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await container.remove({ force: true, v: true }).catch(() => undefined);
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return new DockerSandboxHandle(input.sessionId, this.workspaceDir, container, this.user, this.defaultMaxOutputBytes, this.defaultFileTimeoutMs, this.isolated);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async reapOrphanedContainers(): Promise<number> {
|
|
158
|
+
if (!this.options.docker.listContainers || !this.options.docker.getContainer) {
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const containers = await this.options.docker.listContainers({
|
|
163
|
+
all: true,
|
|
164
|
+
filters: {
|
|
165
|
+
label: ['ct-agents.sandbox=true', `ct-agents.sandbox.owner=${this.ownerId}`],
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
let removed = 0;
|
|
169
|
+
for (const containerInfo of containers) {
|
|
170
|
+
const id = containerInfo.Id ?? containerInfo.id;
|
|
171
|
+
if (!id) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
await this.options.docker.getContainer(id).remove({ force: true, v: true });
|
|
175
|
+
removed += 1;
|
|
176
|
+
}
|
|
177
|
+
return removed;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
182
|
+
const DEFAULT_FILE_TIMEOUT_MS = 30_000;
|
|
183
|
+
const DEFAULT_CPU_QUOTA = 100_000;
|
|
184
|
+
const DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024;
|
|
185
|
+
const DEFAULT_PIDS_LIMIT = 128;
|
|
186
|
+
|
|
187
|
+
export function createDockerSandbox(options: CreateDockerSandboxOptions): DockerSandbox {
|
|
188
|
+
return new DockerSandbox({
|
|
189
|
+
...options,
|
|
190
|
+
runtime: options.useGvisor === false ? 'runc' : 'runsc',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function createDockerSandboxFromDockerode(options: Omit<CreateDockerSandboxOptions, 'docker'>): Promise<DockerSandbox> {
|
|
195
|
+
const Docker = await import('dockerode');
|
|
196
|
+
const docker = new Docker.default({ socketPath: '/var/run/docker.sock' }) as DockerClient;
|
|
197
|
+
return createDockerSandbox({ ...options, docker });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
class DockerSandboxHandle implements SandboxHandle {
|
|
201
|
+
readonly isolated: boolean;
|
|
202
|
+
|
|
203
|
+
constructor(
|
|
204
|
+
readonly sessionId: string,
|
|
205
|
+
readonly workspaceRoot: string,
|
|
206
|
+
private readonly container: DockerContainer,
|
|
207
|
+
private readonly user: string,
|
|
208
|
+
private readonly defaultMaxOutputBytes: number,
|
|
209
|
+
private readonly defaultFileTimeoutMs: number,
|
|
210
|
+
isolated = true,
|
|
211
|
+
) {
|
|
212
|
+
this.isolated = isolated;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async exec(command: string, opts: { timeoutMs?: number; maxOutputBytes?: number } = {}): Promise<ExecResult> {
|
|
216
|
+
return executeInContainer(this.container, buildExecCommand(command, opts.timeoutMs), {
|
|
217
|
+
user: this.user,
|
|
218
|
+
workingDir: this.workspaceRoot,
|
|
219
|
+
timeoutMs: opts.timeoutMs,
|
|
220
|
+
maxOutputBytes: opts.maxOutputBytes ?? this.defaultMaxOutputBytes,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async readFile(path: string, opts: { offset?: number; limit?: number; maxBytes?: number } = {}): Promise<string> {
|
|
225
|
+
const safePath = assertSafeRelativePath(path);
|
|
226
|
+
const readCommand = buildReadFileCommand(safePath, opts, this.workspaceRoot);
|
|
227
|
+
const result = await executeInContainer(this.container, buildExecCommand(readCommand.command, this.defaultFileTimeoutMs), {
|
|
228
|
+
user: this.user,
|
|
229
|
+
workingDir: this.workspaceRoot,
|
|
230
|
+
timeoutMs: this.defaultFileTimeoutMs,
|
|
231
|
+
maxOutputBytes: readCommand.maxOutputBytes ?? this.defaultMaxOutputBytes,
|
|
232
|
+
});
|
|
233
|
+
if (result.exitCode !== 0) {
|
|
234
|
+
throw new Error(result.stderr || `读取文件失败:${path}`);
|
|
235
|
+
}
|
|
236
|
+
if (result.stdoutTruncated) {
|
|
237
|
+
throw new Error(`读取文件超过 sandbox 输出上限:${path}`);
|
|
238
|
+
}
|
|
239
|
+
return decodeCompleteUtf8(decodeBase64Strict(result.stdout, path));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async writeFile(path: string, content: string): Promise<void> {
|
|
243
|
+
const safePath = assertSafeRelativePath(path);
|
|
244
|
+
const encoded = Buffer.from(content, 'utf8').toString('base64');
|
|
245
|
+
const parentDir = dirnamePortable(safePath);
|
|
246
|
+
const fileName = basenamePortable(safePath);
|
|
247
|
+
const command = [
|
|
248
|
+
...buildEnsureWorkspaceDirectoryCommands(parentDir, safePath, this.workspaceRoot),
|
|
249
|
+
buildWorkspacePathGuard('parent', safePath, this.workspaceRoot, true),
|
|
250
|
+
`target="$parent"/${shellQuote(fileName)}`,
|
|
251
|
+
`target_real=$(realpath -m -- "$target")`,
|
|
252
|
+
buildWorkspacePathGuard('target_real', safePath, this.workspaceRoot, false),
|
|
253
|
+
`if [ -e "$target" ] || [ -L "$target" ]; then if [ ! -f "$target" ]; then echo ${shellQuote(`路径不是普通文件:${safePath}`)} >&2; exit 126; fi; fi`,
|
|
254
|
+
`tmp=$(mktemp "$parent"/.ct-agents-write.XXXXXX)`,
|
|
255
|
+
`trap 'rm -f -- "$tmp"' EXIT`,
|
|
256
|
+
`printf %s ${shellQuote(encoded)} | base64 -d > "$tmp"`,
|
|
257
|
+
`mv -fT -- "$tmp" "$target"`,
|
|
258
|
+
`trap - EXIT`,
|
|
259
|
+
].join(' && ');
|
|
260
|
+
const result = await executeInContainer(this.container, buildExecCommand(command, this.defaultFileTimeoutMs), {
|
|
261
|
+
user: this.user,
|
|
262
|
+
workingDir: this.workspaceRoot,
|
|
263
|
+
timeoutMs: this.defaultFileTimeoutMs,
|
|
264
|
+
});
|
|
265
|
+
if (result.exitCode !== 0) {
|
|
266
|
+
throw new Error(result.stderr || `写入文件失败:${path}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async dispose(): Promise<void> {
|
|
271
|
+
await this.container.remove({ force: true, v: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildEnsureWorkspaceDirectoryCommands(parentDir: string, originalPath: string, workspaceRoot: string): string[] {
|
|
276
|
+
const commands = [`parent=${shellQuote(workspaceRoot)}`];
|
|
277
|
+
if (parentDir === '.') {
|
|
278
|
+
return commands;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const segment of parentDir.split('/')) {
|
|
282
|
+
if (!segment || segment === '.') {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
commands.push(`next="$parent"/${shellQuote(segment)}`);
|
|
286
|
+
commands.push(`if [ -L "$next" ]; then echo ${shellQuote(`路径越界:${originalPath}`)} >&2; exit 126; fi`);
|
|
287
|
+
commands.push(`if [ -e "$next" ] && [ ! -d "$next" ]; then echo ${shellQuote(`路径不是目录:${originalPath}`)} >&2; exit 126; fi`);
|
|
288
|
+
commands.push(`mkdir -p -- "$next"`);
|
|
289
|
+
commands.push(`parent=$(realpath -m -- "$next")`);
|
|
290
|
+
commands.push(buildWorkspacePathGuard('parent', originalPath, workspaceRoot, true));
|
|
291
|
+
}
|
|
292
|
+
return commands;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function decodeBase64Strict(value: string, path: string): Buffer {
|
|
296
|
+
const normalized = value.replace(/\s+/g, '');
|
|
297
|
+
if (normalized.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized)) {
|
|
298
|
+
throw new Error(`读取文件返回了损坏的 base64 内容:${path}`);
|
|
299
|
+
}
|
|
300
|
+
return Buffer.from(normalized, 'base64');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function decodeCompleteUtf8(bytes: Uint8Array): string {
|
|
304
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
305
|
+
for (let end = bytes.byteLength; end >= 0; end -= 1) {
|
|
306
|
+
try {
|
|
307
|
+
return decoder.decode(bytes.subarray(0, end));
|
|
308
|
+
} catch {
|
|
309
|
+
// 回退到上一个完整 UTF-8 字符边界。
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return '';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function executeInContainer(
|
|
316
|
+
container: DockerContainer,
|
|
317
|
+
command: string[],
|
|
318
|
+
options: { user: string; workingDir: string; timeoutMs?: number; maxOutputBytes?: number },
|
|
319
|
+
): Promise<ExecResult> {
|
|
320
|
+
const exec = await container.exec({
|
|
321
|
+
Cmd: command,
|
|
322
|
+
AttachStdout: true,
|
|
323
|
+
AttachStderr: true,
|
|
324
|
+
Tty: false,
|
|
325
|
+
User: options.user,
|
|
326
|
+
WorkingDir: options.workingDir,
|
|
327
|
+
});
|
|
328
|
+
const stream = await exec.start({ hijack: true, stdin: false });
|
|
329
|
+
const maxOutputBytes = normalizePositiveInteger(options.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES);
|
|
330
|
+
const stdout = new BoundedBufferSink(maxOutputBytes);
|
|
331
|
+
const stderr = new BoundedBufferSink(maxOutputBytes);
|
|
332
|
+
demuxDockerStream(stream, stdout, stderr, () => stream.destroy?.());
|
|
333
|
+
let timedOut = false;
|
|
334
|
+
const timer = options.timeoutMs
|
|
335
|
+
? setTimeout(() => {
|
|
336
|
+
timedOut = true;
|
|
337
|
+
stream.destroy?.();
|
|
338
|
+
}, options.timeoutMs)
|
|
339
|
+
: undefined;
|
|
340
|
+
await onceEnded(stream);
|
|
341
|
+
if (timer) {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
}
|
|
344
|
+
const inspected = await exec.inspect();
|
|
345
|
+
if ((inspected.Running === true || inspected.ExitCode === null || inspected.ExitCode === undefined) && !timedOut) {
|
|
346
|
+
throw new Error('Docker exec 未完成,无法可靠读取退出码');
|
|
347
|
+
}
|
|
348
|
+
const exitCode = inspected.ExitCode ?? 124;
|
|
349
|
+
const commandTimedOut = timedOut || (options.timeoutMs !== undefined && exitCode === 124);
|
|
350
|
+
return {
|
|
351
|
+
stdout: stdout.text(),
|
|
352
|
+
stderr: stderr.text(),
|
|
353
|
+
exitCode: commandTimedOut ? 124 : exitCode,
|
|
354
|
+
timedOut: commandTimedOut,
|
|
355
|
+
...(stdout.truncated ? { stdoutTruncated: true, totalStdoutBytes: stdout.totalBytes } : {}),
|
|
356
|
+
...(stderr.truncated ? { stderrTruncated: true, totalStderrBytes: stderr.totalBytes } : {}),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class BoundedBufferSink extends Writable {
|
|
361
|
+
private readonly chunks: Buffer[] = [];
|
|
362
|
+
private bufferedBytes = 0;
|
|
363
|
+
totalBytes = 0;
|
|
364
|
+
truncated = false;
|
|
365
|
+
|
|
366
|
+
constructor(private readonly maxBytes: number) {
|
|
367
|
+
super();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
|
371
|
+
this.totalBytes += chunk.byteLength;
|
|
372
|
+
if (!this.truncated) {
|
|
373
|
+
const remainingBytes = this.maxBytes - this.bufferedBytes;
|
|
374
|
+
if (remainingBytes > 0) {
|
|
375
|
+
const stored = Buffer.from(chunk.subarray(0, remainingBytes));
|
|
376
|
+
this.chunks.push(stored);
|
|
377
|
+
this.bufferedBytes += stored.byteLength;
|
|
378
|
+
}
|
|
379
|
+
if (chunk.byteLength > remainingBytes) {
|
|
380
|
+
this.truncated = true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
callback();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
text(): string {
|
|
387
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function demuxDockerStream(
|
|
392
|
+
stream: NodeJS.ReadableStream,
|
|
393
|
+
stdout: BoundedBufferSink,
|
|
394
|
+
stderr: BoundedBufferSink,
|
|
395
|
+
onOutputLimitExceeded: () => void,
|
|
396
|
+
): void {
|
|
397
|
+
let header = Buffer.alloc(0);
|
|
398
|
+
let currentStreamType: number | undefined;
|
|
399
|
+
let remainingPayloadBytes = 0;
|
|
400
|
+
let flushed = false;
|
|
401
|
+
let stopped = false;
|
|
402
|
+
stream.on('data', (chunk: Buffer) => {
|
|
403
|
+
if (stopped) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
let offset = 0;
|
|
407
|
+
while (offset < chunk.length) {
|
|
408
|
+
if (remainingPayloadBytes === 0) {
|
|
409
|
+
const headerBytesNeeded = 8 - header.length;
|
|
410
|
+
const headerChunk = chunk.subarray(offset, offset + headerBytesNeeded);
|
|
411
|
+
header = Buffer.concat([header, headerChunk]);
|
|
412
|
+
offset += headerChunk.byteLength;
|
|
413
|
+
if (header.length < 8) {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
currentStreamType = header[0];
|
|
417
|
+
remainingPayloadBytes = header.readUInt32BE(4);
|
|
418
|
+
header = Buffer.alloc(0);
|
|
419
|
+
if (remainingPayloadBytes === 0) {
|
|
420
|
+
currentStreamType = undefined;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const payloadChunkSize = Math.min(remainingPayloadBytes, chunk.length - offset);
|
|
426
|
+
const payload = chunk.subarray(offset, offset + payloadChunkSize);
|
|
427
|
+
if (currentStreamType === 2) {
|
|
428
|
+
stderr.write(payload);
|
|
429
|
+
} else {
|
|
430
|
+
stdout.write(payload);
|
|
431
|
+
}
|
|
432
|
+
offset += payloadChunkSize;
|
|
433
|
+
remainingPayloadBytes -= payloadChunkSize;
|
|
434
|
+
if (remainingPayloadBytes === 0) {
|
|
435
|
+
currentStreamType = undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (stdout.truncated || stderr.truncated) {
|
|
439
|
+
stopped = true;
|
|
440
|
+
onOutputLimitExceeded();
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
const flush = () => {
|
|
446
|
+
if (flushed) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
flushed = true;
|
|
450
|
+
if (header.length > 0) {
|
|
451
|
+
stdout.write(header);
|
|
452
|
+
header = Buffer.alloc(0);
|
|
453
|
+
}
|
|
454
|
+
stdout.end();
|
|
455
|
+
stderr.end();
|
|
456
|
+
};
|
|
457
|
+
stream.once('end', flush);
|
|
458
|
+
stream.once('close', flush);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function onceEnded(stream: NodeJS.ReadableStream): Promise<void> {
|
|
462
|
+
return new Promise((resolveEnded, reject) => {
|
|
463
|
+
stream.once('end', resolveEnded);
|
|
464
|
+
stream.once('close', resolveEnded);
|
|
465
|
+
stream.once('error', reject);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function buildExecCommand(command: string, timeoutMs?: number): string[] {
|
|
470
|
+
if (!timeoutMs) {
|
|
471
|
+
return ['sh', '-lc', command];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return [
|
|
475
|
+
'timeout',
|
|
476
|
+
'--kill-after=1s',
|
|
477
|
+
formatTimeoutSeconds(timeoutMs),
|
|
478
|
+
'sh',
|
|
479
|
+
'-lc',
|
|
480
|
+
command,
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function formatTimeoutSeconds(timeoutMs: number): string {
|
|
485
|
+
return `${Math.max(0.001, timeoutMs / 1000)}s`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function sanitizeDockerName(value: string): string {
|
|
489
|
+
return value.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
|
493
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
494
|
+
return fallback;
|
|
495
|
+
}
|
|
496
|
+
return Math.max(1, Math.trunc(value));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizeOptionalPositiveInteger(value: number | undefined): number | undefined {
|
|
500
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
return Math.max(1, Math.trunc(value));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function normalizeOptionalNonNegativeInteger(value: number | undefined): number | undefined {
|
|
507
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
return Math.max(0, Math.trunc(value));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function buildReadFileCommand(
|
|
514
|
+
safePath: string,
|
|
515
|
+
opts: { offset?: number; limit?: number; maxBytes?: number },
|
|
516
|
+
workspaceRoot: string,
|
|
517
|
+
): { command: string; maxOutputBytes?: number } {
|
|
518
|
+
const offset = normalizeOptionalNonNegativeInteger(opts.offset);
|
|
519
|
+
const limit = normalizeOptionalPositiveInteger(opts.limit);
|
|
520
|
+
const maxBytes = normalizeOptionalPositiveInteger(opts.maxBytes);
|
|
521
|
+
const readBytes = minDefined(limit, maxBytes);
|
|
522
|
+
const guard = [
|
|
523
|
+
`target=$(realpath -m -- ${shellQuote(safePath)})`,
|
|
524
|
+
buildWorkspacePathGuard('target', safePath, workspaceRoot, false),
|
|
525
|
+
].join(' && ');
|
|
526
|
+
const source = offset === undefined
|
|
527
|
+
? 'cat "$target"'
|
|
528
|
+
: `tail -c +${offset + 1} "$target"`;
|
|
529
|
+
const limited = readBytes === undefined
|
|
530
|
+
? source
|
|
531
|
+
: `${source} | head -c ${readBytes}`;
|
|
532
|
+
return {
|
|
533
|
+
command: `${guard} && ${limited} | base64`,
|
|
534
|
+
...(readBytes !== undefined ? { maxOutputBytes: encodedBase64Bytes(readBytes) + 4096 } : {}),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function buildWorkspacePathGuard(
|
|
539
|
+
variableName: string,
|
|
540
|
+
originalPath: string,
|
|
541
|
+
workspaceRoot: string,
|
|
542
|
+
allowWorkspaceRoot: boolean,
|
|
543
|
+
): string {
|
|
544
|
+
const quotedWorkspace = shellQuote(workspaceRoot);
|
|
545
|
+
const rootCase = allowWorkspaceRoot ? `${quotedWorkspace}|` : '';
|
|
546
|
+
return `case "$${variableName}" in ${rootCase}${quotedWorkspace}/*) ;; *) echo ${shellQuote(`路径越界:${originalPath}`)} >&2; exit 126 ;; esac`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function minDefined(left: number | undefined, right: number | undefined): number | undefined {
|
|
550
|
+
if (left === undefined) {
|
|
551
|
+
return right;
|
|
552
|
+
}
|
|
553
|
+
if (right === undefined) {
|
|
554
|
+
return left;
|
|
555
|
+
}
|
|
556
|
+
return Math.min(left, right);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function encodedBase64Bytes(rawBytes: number): number {
|
|
560
|
+
return Math.ceil(rawBytes / 3) * 4;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function assertSafeRelativePath(path: string): string {
|
|
564
|
+
if (!path || path.startsWith('/') || path.includes('\0')) {
|
|
565
|
+
throw new Error(`路径越界:${path}`);
|
|
566
|
+
}
|
|
567
|
+
const normalized = posix.normalize(path);
|
|
568
|
+
const segments = normalized.split('/');
|
|
569
|
+
if (normalized === '.' || normalized.startsWith('../') || segments.includes('..')) {
|
|
570
|
+
throw new Error(`路径越界:${path}`);
|
|
571
|
+
}
|
|
572
|
+
return normalized;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function dirnamePortable(path: string): string {
|
|
576
|
+
const index = path.lastIndexOf('/');
|
|
577
|
+
return index <= 0 ? '.' : path.slice(0, index);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function basenamePortable(path: string): string {
|
|
581
|
+
const index = path.lastIndexOf('/');
|
|
582
|
+
return index < 0 ? path : path.slice(index + 1);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function shellQuote(value: string): string {
|
|
586
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
587
|
+
}
|