feat(agent): sandbox conversation analysis
This commit is contained in:
+6
-51
@@ -1,6 +1,9 @@
|
||||
import { lstat, realpath, stat } from "node:fs/promises";
|
||||
import { isAbsolute, relative } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
resolveConversationWorkspace,
|
||||
resolveExistingPathInsideRoot,
|
||||
} from "../runtime/conversationWorkspace.js";
|
||||
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
|
||||
import {
|
||||
type ResultReferenceKind,
|
||||
@@ -76,7 +79,7 @@ export class ResultReferenceResolver {
|
||||
input.workspaceDirectory,
|
||||
this.importRoot,
|
||||
);
|
||||
const resolvedFilePath = await resolvePathInsideRoot(
|
||||
const resolvedFilePath = await resolveExistingPathInsideRoot(
|
||||
filePath,
|
||||
resolvedWorkspaceDirectory,
|
||||
"render payload file must be inside the current conversation workspace",
|
||||
@@ -197,54 +200,6 @@ export const extractRenderJunctionPayload = (
|
||||
};
|
||||
};
|
||||
|
||||
const resolveConversationWorkspace = async (
|
||||
workspaceDirectory: string,
|
||||
importRoot: string,
|
||||
) => {
|
||||
const workspaceLinkStat = await lstat(workspaceDirectory);
|
||||
if (workspaceLinkStat.isSymbolicLink()) {
|
||||
throw new Error("conversation workspace must not be a symbolic link");
|
||||
}
|
||||
const resolvedImportRoot = await realpath(importRoot);
|
||||
const resolvedWorkspaceDirectory = await resolvePathInsideRoot(
|
||||
workspaceDirectory,
|
||||
resolvedImportRoot,
|
||||
"conversation workspace must be inside RESULT_REF_IMPORT_DIR",
|
||||
);
|
||||
const relativeWorkspace = relative(resolvedImportRoot, resolvedWorkspaceDirectory);
|
||||
if (!relativeWorkspace || relativeWorkspace.includes("/") || relativeWorkspace.includes("\\")) {
|
||||
throw new Error("conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR");
|
||||
}
|
||||
const workspaceStat = await stat(resolvedWorkspaceDirectory);
|
||||
if (!workspaceStat.isDirectory()) {
|
||||
throw new Error("conversation workspace must point to a directory");
|
||||
}
|
||||
return resolvedWorkspaceDirectory;
|
||||
};
|
||||
|
||||
const resolvePathInsideRoot = async (
|
||||
filePath: string,
|
||||
rootPath: string,
|
||||
outsideMessage = "render payload file must be inside RESULT_REF_IMPORT_DIR",
|
||||
) => {
|
||||
if (!isAbsolute(filePath)) {
|
||||
throw new Error("render payload file_path must be absolute");
|
||||
}
|
||||
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
|
||||
realpath(filePath),
|
||||
realpath(rootPath),
|
||||
]);
|
||||
const relativePath = relative(resolvedRootPath, resolvedFilePath);
|
||||
if (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
|
||||
isAbsolute(relativePath)
|
||||
) {
|
||||
throw new Error(outsideMessage);
|
||||
}
|
||||
return resolvedFilePath;
|
||||
};
|
||||
|
||||
const normalizeDataForKind = (
|
||||
kind: ResultReferenceKind,
|
||||
data: unknown,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export type ApprovalMode = "request" | "auto" | "always";
|
||||
|
||||
@@ -46,6 +46,14 @@ export const canAutoApprovePermission = (
|
||||
return isSafeWorkspaceSearch(normalized, context);
|
||||
}
|
||||
|
||||
if (normalized === "bash") {
|
||||
return isConversationWorkspace(context.workspaceRoot);
|
||||
}
|
||||
|
||||
if (normalized === "read" || normalized === "edit" || normalized === "write") {
|
||||
return isSafeConversationFileAccess(context);
|
||||
}
|
||||
|
||||
if (lowRiskToolPermissions.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
@@ -71,19 +79,6 @@ export const resolvePermissionApproval = (
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (
|
||||
approvalMode === "always" &&
|
||||
normalizePermission(permission) === "bash" &&
|
||||
containsPotentialRemoveCommand(context)
|
||||
) {
|
||||
return {
|
||||
autoApprove: false,
|
||||
autoReject: false,
|
||||
title: "等待删除命令确认",
|
||||
detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (approvalMode === "always") {
|
||||
return {
|
||||
autoApprove: true,
|
||||
@@ -204,22 +199,6 @@ const isDirectRecursiveForceRemove = (
|
||||
});
|
||||
};
|
||||
|
||||
const containsPotentialRemoveCommand = (
|
||||
context: PermissionApprovalContext,
|
||||
): boolean => {
|
||||
const command =
|
||||
typeof context.metadata?.command === "string"
|
||||
? context.metadata.command
|
||||
: context.patterns?.join("\n");
|
||||
if (!command) {
|
||||
return false;
|
||||
}
|
||||
const normalized = command
|
||||
.replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "")
|
||||
.replace(/["'\\]/gu, "");
|
||||
return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized);
|
||||
};
|
||||
|
||||
const splitShellCommandSegments = (command: string): string[] =>
|
||||
command.split(/&&|\|\||[;|()\n]/u);
|
||||
|
||||
@@ -281,13 +260,14 @@ const isSafeWorkspaceSearch = (
|
||||
isAbsolute(expression) ||
|
||||
containsParentTraversal(expression) ||
|
||||
containsAmbiguousGlobSyntax(expression) ||
|
||||
containsProtectedPath(expression),
|
||||
containsProtectedPath(expression, isConversationWorkspace(root)),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!relativePath) {
|
||||
const conversationWorkspace = isConversationWorkspace(root);
|
||||
if (!relativePath && !conversationWorkspace) {
|
||||
if (permission !== "glob") {
|
||||
return false;
|
||||
}
|
||||
@@ -304,16 +284,19 @@ const isSafeWorkspaceSearch = (
|
||||
}
|
||||
|
||||
return (
|
||||
relativePath !== "" &&
|
||||
relativePath !== ".." &&
|
||||
!relativePath.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(relativePath) &&
|
||||
!containsProtectedPath(relativePath) &&
|
||||
isSafeSearchTarget(searchRoot, relativePath)
|
||||
!containsProtectedPath(relativePath, conversationWorkspace) &&
|
||||
isSafeSearchTarget(searchRoot, relativePath, conversationWorkspace)
|
||||
);
|
||||
};
|
||||
|
||||
const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => {
|
||||
const isSafeSearchTarget = (
|
||||
searchRoot: string,
|
||||
relativePath: string,
|
||||
conversationWorkspace: boolean,
|
||||
): boolean => {
|
||||
try {
|
||||
const target = lstatSync(searchRoot);
|
||||
if (target.isFile()) {
|
||||
@@ -323,7 +306,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean =
|
||||
return false;
|
||||
}
|
||||
const topLevelName = relativePath.split(sep)[0];
|
||||
if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) {
|
||||
if (
|
||||
!conversationWorkspace &&
|
||||
(!topLevelName || !lowRiskSearchRootNames.has(topLevelName))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -331,7 +317,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean =
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()!;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) {
|
||||
if (
|
||||
entry.isSymbolicLink() ||
|
||||
containsProtectedPath(entry.name, conversationWorkspace)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
@@ -361,10 +350,71 @@ const containsParentTraversal = (value: string): boolean =>
|
||||
const containsAmbiguousGlobSyntax = (value: string): boolean =>
|
||||
/[?[\]{}()!+@\\]/.test(value);
|
||||
|
||||
const containsProtectedPath = (value: string): boolean => {
|
||||
const containsProtectedPath = (
|
||||
value: string,
|
||||
conversationWorkspace = false,
|
||||
): boolean => {
|
||||
const normalized = value.replaceAll("\\", "/").toLowerCase();
|
||||
return (
|
||||
normalized.includes(".env") ||
|
||||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)
|
||||
(!conversationWorkspace &&
|
||||
/(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized))
|
||||
);
|
||||
};
|
||||
|
||||
const isConversationWorkspace = (workspaceRoot: string | undefined): boolean => {
|
||||
if (!workspaceRoot?.trim()) return false;
|
||||
try {
|
||||
const root = realpathSync.native(resolve(workspaceRoot));
|
||||
return (
|
||||
basename(dirname(root)) === "conversation-workspaces" &&
|
||||
basename(root).startsWith("conversation-") &&
|
||||
lstatSync(root).isDirectory()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isSafeConversationFileAccess = (
|
||||
context: PermissionApprovalContext,
|
||||
): boolean => {
|
||||
if (!isConversationWorkspace(context.workspaceRoot)) return false;
|
||||
const requestedPath = [
|
||||
context.metadata?.path,
|
||||
context.metadata?.file_path,
|
||||
context.metadata?.filePath,
|
||||
context.metadata?.filepath,
|
||||
context.metadata?.file,
|
||||
context.patterns?.[0],
|
||||
].find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
if (!requestedPath || /[*?[\]{}]/.test(requestedPath)) return false;
|
||||
try {
|
||||
const root = realpathSync.native(resolve(context.workspaceRoot!));
|
||||
const target = resolve(root, requestedPath);
|
||||
let existingPath = target;
|
||||
while (!existsSync(existingPath)) {
|
||||
const parent = dirname(existingPath);
|
||||
if (parent === existingPath) return false;
|
||||
existingPath = parent;
|
||||
}
|
||||
const resolvedExistingPath = realpathSync.native(existingPath);
|
||||
const relativeExisting = relative(root, resolvedExistingPath);
|
||||
if (
|
||||
relativeExisting === ".." ||
|
||||
relativeExisting.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativeExisting)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const relativeTarget = relative(root, target);
|
||||
return (
|
||||
relativeTarget !== ".." &&
|
||||
!relativeTarget.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(relativeTarget) &&
|
||||
!containsProtectedPath(relativeTarget, true)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -399,7 +399,7 @@ export const streamPromptResponse = async ({
|
||||
{
|
||||
metadata: event.properties.metadata,
|
||||
patterns: event.properties.patterns,
|
||||
workspaceRoot: workspaceRoot ?? process.cwd(),
|
||||
workspaceRoot,
|
||||
},
|
||||
);
|
||||
logDevelopmentDebug("permission request received", {
|
||||
@@ -455,7 +455,7 @@ export const streamPromptResponse = async ({
|
||||
{
|
||||
metadata: event.properties.metadata,
|
||||
patterns: event.properties.resources,
|
||||
workspaceRoot: workspaceRoot ?? process.cwd(),
|
||||
workspaceRoot,
|
||||
},
|
||||
);
|
||||
logDevelopmentDebug("permission v2 request received", {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
chown,
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const SANDBOX_UID = 10_001;
|
||||
export const SANDBOX_GID = 10_001;
|
||||
|
||||
const isOutsideRoot = (root: string, candidate: string) => {
|
||||
const relativePath = relative(root, candidate);
|
||||
return (
|
||||
relativePath === ".." ||
|
||||
relativePath.startsWith(`..${sep}`) ||
|
||||
isAbsolute(relativePath)
|
||||
);
|
||||
};
|
||||
|
||||
export const setSandboxOwnership = async (path: string) => {
|
||||
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
||||
return;
|
||||
}
|
||||
await chown(path, SANDBOX_UID, SANDBOX_GID);
|
||||
};
|
||||
|
||||
export const resolveConversationWorkspace = async (
|
||||
workspaceDirectory: string,
|
||||
importRoot: string,
|
||||
) => {
|
||||
const workspaceLinkStat = await lstat(workspaceDirectory);
|
||||
if (workspaceLinkStat.isSymbolicLink()) {
|
||||
throw new Error("conversation workspace must not be a symbolic link");
|
||||
}
|
||||
const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([
|
||||
realpath(importRoot),
|
||||
realpath(workspaceDirectory),
|
||||
]);
|
||||
if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) {
|
||||
throw new Error("conversation workspace must be inside RESULT_REF_IMPORT_DIR");
|
||||
}
|
||||
const relativeWorkspace = relative(
|
||||
resolvedImportRoot,
|
||||
resolvedWorkspaceDirectory,
|
||||
);
|
||||
if (
|
||||
!relativeWorkspace ||
|
||||
relativeWorkspace.includes("/") ||
|
||||
relativeWorkspace.includes("\\")
|
||||
) {
|
||||
throw new Error(
|
||||
"conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR",
|
||||
);
|
||||
}
|
||||
const workspaceStat = await stat(resolvedWorkspaceDirectory);
|
||||
if (!workspaceStat.isDirectory()) {
|
||||
throw new Error("conversation workspace must point to a directory");
|
||||
}
|
||||
return resolvedWorkspaceDirectory;
|
||||
};
|
||||
|
||||
export const resolveExistingPathInsideRoot = async (
|
||||
filePath: string,
|
||||
rootPath: string,
|
||||
outsideMessage = "path must be inside the current conversation workspace",
|
||||
) => {
|
||||
if (!isAbsolute(filePath)) {
|
||||
throw new Error("file_path must be absolute");
|
||||
}
|
||||
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
|
||||
realpath(filePath),
|
||||
realpath(rootPath),
|
||||
]);
|
||||
if (isOutsideRoot(resolvedRootPath, resolvedFilePath)) {
|
||||
throw new Error(outsideMessage);
|
||||
}
|
||||
return resolvedFilePath;
|
||||
};
|
||||
|
||||
export type StagedConversationFile = {
|
||||
bytes: number;
|
||||
contentType: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export const stageConversationText = async (
|
||||
workspaceDirectory: string,
|
||||
importRoot: string,
|
||||
content: string,
|
||||
options: {
|
||||
contentType: string;
|
||||
extension: string;
|
||||
prefix: string;
|
||||
},
|
||||
): Promise<StagedConversationFile> => {
|
||||
const workspace = await resolveConversationWorkspace(
|
||||
workspaceDirectory,
|
||||
importRoot,
|
||||
);
|
||||
const outputDirectory = resolve(workspace, "tool-data");
|
||||
if (isOutsideRoot(workspace, outputDirectory)) {
|
||||
throw new Error("tool data directory escaped the conversation workspace");
|
||||
}
|
||||
await mkdir(outputDirectory, { mode: 0o700, recursive: true });
|
||||
const resolvedOutputDirectory = await realpath(outputDirectory);
|
||||
if (isOutsideRoot(workspace, resolvedOutputDirectory)) {
|
||||
throw new Error("tool data directory escaped the conversation workspace");
|
||||
}
|
||||
await chmod(resolvedOutputDirectory, 0o700);
|
||||
await setSandboxOwnership(resolvedOutputDirectory);
|
||||
|
||||
const fileName = `${options.prefix}-${randomUUID()}.${options.extension}`;
|
||||
const filePath = join(resolvedOutputDirectory, fileName);
|
||||
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
||||
await setSandboxOwnership(temporaryPath);
|
||||
await rename(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await chmod(filePath, 0o600);
|
||||
await setSandboxOwnership(filePath);
|
||||
return {
|
||||
bytes: Buffer.byteLength(content),
|
||||
contentType: options.contentType,
|
||||
filePath,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { resolve } from "node:path";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { ensureDirectory } from "../utils/fileStore.js";
|
||||
import { setSandboxOwnership } from "./conversationWorkspace.js";
|
||||
import {
|
||||
cleanupExpiredToolOutputs,
|
||||
resolveOpencodeToolOutputDirectory,
|
||||
@@ -149,6 +150,7 @@ export class OpencodeRuntimeAdapter {
|
||||
await ensureDirectory(workspaceRoot);
|
||||
await chmod(workspaceRoot, 0o700);
|
||||
await mkdir(directory, { mode: 0o700 });
|
||||
await setSandboxOwnership(directory);
|
||||
try {
|
||||
const response = await client.session.create({
|
||||
directory,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { config } from "../config.js";
|
||||
import { type RuntimeSessionContext } from "./sessionContext.js";
|
||||
import { stageConversationText } from "./conversationWorkspace.js";
|
||||
|
||||
export type ToolDataFilePayload = {
|
||||
bytes: number;
|
||||
content_type: string;
|
||||
file_path: string;
|
||||
};
|
||||
|
||||
export const stageLargeToolOutput = async (
|
||||
context: RuntimeSessionContext,
|
||||
content: string,
|
||||
options: {
|
||||
contentType: string;
|
||||
extension: string;
|
||||
force?: boolean;
|
||||
prefix: string;
|
||||
},
|
||||
): Promise<ToolDataFilePayload | null> => {
|
||||
if (!options.force && Buffer.byteLength(content) <= config.MAX_INLINE_RESULT_BYTES) {
|
||||
return null;
|
||||
}
|
||||
if (!context.workspaceDirectory) {
|
||||
throw new Error(
|
||||
"large tool output requires a conversation workspace; create a new conversation",
|
||||
);
|
||||
}
|
||||
const staged = await stageConversationText(
|
||||
context.workspaceDirectory,
|
||||
config.RESULT_REF_IMPORT_DIR,
|
||||
content,
|
||||
options,
|
||||
);
|
||||
return {
|
||||
bytes: staged.bytes,
|
||||
content_type: staged.contentType,
|
||||
file_path: staged.filePath,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildLargeCliResult = (dataFile: ToolDataFilePayload) => ({
|
||||
ok: true,
|
||||
schema_version: "tjwater-cli/v1",
|
||||
summary: "CLI 结果已保存到当前对话工作区",
|
||||
data_file: dataFile,
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
resolveConversationWorkspace,
|
||||
SANDBOX_GID,
|
||||
SANDBOX_UID,
|
||||
setSandboxOwnership,
|
||||
} from "../runtime/conversationWorkspace.js";
|
||||
|
||||
export type SandboxProbe = {
|
||||
landlockAbi: number;
|
||||
seccomp: boolean;
|
||||
};
|
||||
|
||||
export type SandboxExecutionResult = {
|
||||
exitCode: number | null;
|
||||
outcome: "completed" | "output_limit" | "timeout";
|
||||
signal: NodeJS.Signals | null;
|
||||
stderr: string;
|
||||
stderrTruncated: boolean;
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py");
|
||||
const maxSandboxTimeoutSeconds = 30 * 60;
|
||||
|
||||
const resolvePythonPath = () => {
|
||||
const virtualEnvironment = process.env.VIRTUAL_ENV?.trim();
|
||||
const candidates = [
|
||||
virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "",
|
||||
"/opt/venv/bin/python",
|
||||
"/usr/bin/python3",
|
||||
];
|
||||
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3";
|
||||
};
|
||||
|
||||
const collectProcess = async (
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
maxStderrBytes: number;
|
||||
maxStdoutBytes: number;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<SandboxExecutionResult> => {
|
||||
const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], {
|
||||
cwd: options.cwd,
|
||||
detached: true,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let stderrTruncated = false;
|
||||
let outcome: SandboxExecutionResult["outcome"] = "completed";
|
||||
let terminationStarted = false;
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const killGroup = (signal: NodeJS.Signals) => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child when process groups are unavailable.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
};
|
||||
const terminate = (nextOutcome: "output_limit" | "timeout") => {
|
||||
if (terminationStarted) return;
|
||||
terminationStarted = true;
|
||||
outcome = nextOutcome;
|
||||
killGroup("SIGTERM");
|
||||
forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500);
|
||||
};
|
||||
|
||||
const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs);
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
if (terminationStarted) return;
|
||||
if (stdoutBytes + chunk.length > options.maxStdoutBytes) {
|
||||
terminate("output_limit");
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
stdoutBytes += chunk.length;
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
if (stderrTruncated) return;
|
||||
const remaining = options.maxStderrBytes - stderrBytes;
|
||||
if (chunk.length > remaining) {
|
||||
if (remaining > 0) {
|
||||
stderrChunks.push(chunk.subarray(0, remaining));
|
||||
stderrBytes += remaining;
|
||||
}
|
||||
stderrTruncated = true;
|
||||
return;
|
||||
}
|
||||
stderrChunks.push(chunk);
|
||||
stderrBytes += chunk.length;
|
||||
});
|
||||
|
||||
return await new Promise((resolveResult, reject) => {
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeoutTimer);
|
||||
if (forceKillTimer) clearTimeout(forceKillTimer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (exitCode, signal) => {
|
||||
clearTimeout(timeoutTimer);
|
||||
if (forceKillTimer) clearTimeout(forceKillTimer);
|
||||
resolveResult({
|
||||
exitCode,
|
||||
outcome,
|
||||
signal,
|
||||
stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"),
|
||||
stderrTruncated,
|
||||
stdout:
|
||||
outcome === "output_limit"
|
||||
? ""
|
||||
: Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const probeLandlockSandbox = async (): Promise<SandboxProbe> => {
|
||||
const result = await collectProcess(["--probe"], {
|
||||
maxStderrBytes: 16 * 1024,
|
||||
maxStdoutBytes: 16 * 1024,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || "Landlock sandbox probe failed");
|
||||
}
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
landlock_abi?: unknown;
|
||||
seccomp?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof payload.landlock_abi !== "number" ||
|
||||
payload.landlock_abi < 4 ||
|
||||
payload.seccomp !== true
|
||||
) {
|
||||
throw new Error("Landlock ABI 4 and seccomp are required");
|
||||
}
|
||||
return { landlockAbi: payload.landlock_abi, seccomp: true };
|
||||
};
|
||||
|
||||
const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({
|
||||
HOME: workspace,
|
||||
LANG: process.env.LANG ?? "C.UTF-8",
|
||||
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
||||
MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"),
|
||||
PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
|
||||
PYTHONDONTWRITEBYTECODE: "1",
|
||||
TMPDIR: resolve(workspace, "tmp"),
|
||||
TZ: process.env.TZ ?? "Asia/Shanghai",
|
||||
VIRTUAL_ENV: "/opt/venv",
|
||||
XDG_CACHE_HOME: resolve(workspace, ".cache"),
|
||||
});
|
||||
|
||||
export const executeSandboxCommand = async (
|
||||
workspaceDirectory: string,
|
||||
command: string,
|
||||
timeoutSeconds: number,
|
||||
): Promise<SandboxExecutionResult> => {
|
||||
const workspace = await resolveConversationWorkspace(
|
||||
workspaceDirectory,
|
||||
config.RESULT_REF_IMPORT_DIR,
|
||||
);
|
||||
const normalizedTimeout = Math.min(
|
||||
maxSandboxTimeoutSeconds,
|
||||
Math.max(1, Math.trunc(timeoutSeconds)),
|
||||
);
|
||||
for (const path of [resolve(workspace, "tmp"), resolve(workspace, ".cache")]) {
|
||||
await mkdir(path, { mode: 0o700, recursive: true });
|
||||
await setSandboxOwnership(path);
|
||||
}
|
||||
|
||||
const readOnlyPaths = [
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/opt/venv",
|
||||
"/etc/ld.so.cache",
|
||||
"/etc/localtime",
|
||||
"/etc/group",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/passwd",
|
||||
resolve(config.OPENCODE_SKILLS_ROOT_DIR),
|
||||
].filter(existsSync);
|
||||
const args = [
|
||||
"--workspace",
|
||||
workspace,
|
||||
"--uid",
|
||||
String(SANDBOX_UID),
|
||||
"--gid",
|
||||
String(SANDBOX_GID),
|
||||
...readOnlyPaths.flatMap((path) => ["--read-only", path]),
|
||||
"--command",
|
||||
command,
|
||||
];
|
||||
return collectProcess(args, {
|
||||
cwd: workspace,
|
||||
env: buildSandboxEnvironment(workspace),
|
||||
maxStderrBytes: config.MAX_CLI_STDERR_BYTES,
|
||||
maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES,
|
||||
timeoutMs: normalizedTimeout * 1000,
|
||||
});
|
||||
};
|
||||
+116
@@ -32,11 +32,20 @@ import {
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { buildAgentPublicRouter } from "./routes/publicApi.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import {
|
||||
executeSandboxCommand,
|
||||
probeLandlockSandbox,
|
||||
type SandboxProbe,
|
||||
} from "./sandbox/landlockSandbox.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
markRuntimeSessionAuthExpired,
|
||||
type RuntimeSessionContext,
|
||||
} from "./runtime/sessionContext.js";
|
||||
import {
|
||||
buildLargeCliResult,
|
||||
stageLargeToolOutput,
|
||||
} from "./runtime/toolOutputStaging.js";
|
||||
import { ensureDirectory } from "./utils/fileStore.js";
|
||||
import { SkillStore } from "./skills/store.js";
|
||||
|
||||
@@ -63,6 +72,7 @@ const resultReferenceResolver = new ResultReferenceResolver(
|
||||
);
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||
const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
|
||||
let sandboxProbe: SandboxProbe | null = null;
|
||||
|
||||
// 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。
|
||||
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||
@@ -78,6 +88,7 @@ app.get("/health", async (_req, res) => {
|
||||
ready: true,
|
||||
warmed_up: true,
|
||||
runtime,
|
||||
sandbox: sandboxProbe,
|
||||
sessions: sessionBridge.count(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -215,6 +226,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
|
||||
const timeoutSec =
|
||||
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
|
||||
const storeResult = req.body?.store_result === true;
|
||||
|
||||
if (!context.network) {
|
||||
res.status(400).json({
|
||||
@@ -309,6 +321,24 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
}
|
||||
|
||||
if (result.stdout.trim()) {
|
||||
try {
|
||||
const dataFile = await stageLargeToolOutput(context, result.stdout, {
|
||||
contentType: "application/json",
|
||||
extension: "json",
|
||||
force: storeResult,
|
||||
prefix: "cli",
|
||||
});
|
||||
if (dataFile) {
|
||||
res.status(200).json(buildLargeCliResult(dataFile));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(409).json({
|
||||
message: "large CLI result could not be staged",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(200).type("application/json").send(result.stdout);
|
||||
return;
|
||||
}
|
||||
@@ -321,6 +351,91 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/internal/tools/sandbox-shell", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const sessionId =
|
||||
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const command =
|
||||
typeof req.body?.command === "string" ? req.body.command.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
if (!context) {
|
||||
res.status(404).json({ message: "session context not found", detail: sessionId });
|
||||
return;
|
||||
}
|
||||
if (!context.workspaceDirectory) {
|
||||
res.status(400).json({
|
||||
message: "conversation workspace is required",
|
||||
detail: "create a new conversation before running shell commands",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
res.status(400).json({ message: "command is required" });
|
||||
return;
|
||||
}
|
||||
const timeoutSeconds =
|
||||
typeof req.body?.timeout === "number" && Number.isFinite(req.body.timeout)
|
||||
? req.body.timeout
|
||||
: 120;
|
||||
try {
|
||||
const result = await executeSandboxCommand(
|
||||
context.workspaceDirectory,
|
||||
command,
|
||||
timeoutSeconds,
|
||||
);
|
||||
if (result.outcome === "timeout") {
|
||||
res.status(504).json({
|
||||
ok: false,
|
||||
error: { code: "TIMEOUT", message: "sandbox command timed out" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.outcome === "output_limit") {
|
||||
res.status(502).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "OUTPUT_LIMIT_EXCEEDED",
|
||||
message: `stdout exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.exitCode === 125) {
|
||||
res.status(503).json({
|
||||
ok: false,
|
||||
error: { code: "SANDBOX_UNAVAILABLE", message: result.stderr },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dataFile = result.stdout
|
||||
? await stageLargeToolOutput(context, result.stdout, {
|
||||
contentType: "text/plain",
|
||||
extension: "txt",
|
||||
prefix: "shell",
|
||||
})
|
||||
: null;
|
||||
res.json({
|
||||
ok: result.exitCode === 0,
|
||||
exit_code: result.exitCode,
|
||||
signal: result.signal,
|
||||
...(dataFile ? { data_file: dataFile } : { stdout: result.stdout }),
|
||||
stderr: result.stderr || undefined,
|
||||
stderr_truncated: result.stderrTruncated || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "SANDBOX_UNAVAILABLE",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
@@ -626,6 +741,7 @@ app.use(
|
||||
);
|
||||
|
||||
const bootstrap = async () => {
|
||||
sandboxProbe = await probeLandlockSandbox();
|
||||
await Promise.all([
|
||||
sessionMetadataStore.initialize(),
|
||||
sessionUiStateStore.initialize(),
|
||||
|
||||
Reference in New Issue
Block a user