auto-save 2026-04-19 21:06 (+6, ~4)
This commit is contained in:
@@ -139,6 +139,13 @@
|
|||||||
"message": "auto-save 2026-04-19 20:15 (~1)",
|
"message": "auto-save 2026-04-19 20:15 (~1)",
|
||||||
"hash": "eea4230",
|
"hash": "eea4230",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-04-19T21:01:27+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-04-19 21:01 (+3, ~2)",
|
||||||
|
"hash": "1b8a216",
|
||||||
|
"files_changed": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
25
deploy/sandbox-orchestrator.service
Normal file
25
deploy/sandbox-orchestrator.service
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lobe Sandbox Orchestrator
|
||||||
|
After=network-online.target incus.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=incus.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=/opt/lobe-sandbox/orchestrator
|
||||||
|
EnvironmentFile=/etc/lobe-sandbox/orchestrator.env
|
||||||
|
ExecStart=/usr/local/bin/bun run src/index.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
StandardOutput=append:/var/log/lobe-sandbox-orchestrator.log
|
||||||
|
StandardError=append:/var/log/lobe-sandbox-orchestrator.log
|
||||||
|
|
||||||
|
# 资源限额
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
0
images/base/build.sh
Normal file → Executable file
0
images/base/build.sh
Normal file → Executable file
0
images/base/patch-uv.sh
Normal file → Executable file
0
images/base/patch-uv.sh
Normal file → Executable file
83
orchestrator/src/export.ts
Normal file
83
orchestrator/src/export.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { env } from './env.ts';
|
||||||
|
import { incus, containerName } from './incus.ts';
|
||||||
|
import { state } from './state.ts';
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
endpoint: env.s3.endpoint,
|
||||||
|
region: env.s3.region,
|
||||||
|
forcePathStyle: env.s3.forcePathStyle,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.s3.accessKeyId,
|
||||||
|
secretAccessKey: env.s3.secretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
success: boolean;
|
||||||
|
key?: string;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
filename?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportFile = async (
|
||||||
|
userId: string,
|
||||||
|
path: string,
|
||||||
|
filename: string,
|
||||||
|
): Promise<ExportResult> => {
|
||||||
|
const name = containerName(userId);
|
||||||
|
const s = await incus.state(name);
|
||||||
|
if (s === 'MISSING') return { success: false, error: `no sandbox for ${userId}` };
|
||||||
|
if (s === 'STOPPED') await incus.start(name);
|
||||||
|
state.touch(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = await incus.pull(name, path);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const key = `exports/${today}/${userId}/${filename}`;
|
||||||
|
const mime = guessMime(filename);
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: env.s3.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: bytes,
|
||||||
|
ContentType: mime,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, key, size: bytes.byteLength, mimeType: mime, filename };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.tsv': 'text/tab-separated-values',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const guessMime = (filename: string): string => {
|
||||||
|
const dot = filename.lastIndexOf('.');
|
||||||
|
if (dot === -1) return 'application/octet-stream';
|
||||||
|
return MIME[filename.slice(dot).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
};
|
||||||
96
orchestrator/src/index.ts
Normal file
96
orchestrator/src/index.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authMiddleware } from './auth.ts';
|
||||||
|
import { env } from './env.ts';
|
||||||
|
import { exportFile } from './export.ts';
|
||||||
|
import { containerName, incus } from './incus.ts';
|
||||||
|
import { startIdleReaper } from './reaper.ts';
|
||||||
|
import { state } from './state.ts';
|
||||||
|
import { handlers } from './tools.ts';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/health', (c) => c.json({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
|
// 下面所有路由都要过 auth
|
||||||
|
app.use('/api/*', authMiddleware);
|
||||||
|
|
||||||
|
// ---- User lifecycle ----
|
||||||
|
|
||||||
|
app.post('/api/v1/users', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const parsed = z.object({ userId: z.string().min(1).max(64) }).safeParse(body);
|
||||||
|
if (!parsed.success) return c.json({ error: 'invalid body' }, 400);
|
||||||
|
const { userId } = parsed.data;
|
||||||
|
|
||||||
|
state.recordCreate(userId);
|
||||||
|
try {
|
||||||
|
await incus.provision(containerName(userId));
|
||||||
|
state.markProvisioned(userId);
|
||||||
|
return c.json({ success: true, userId, containerName: containerName(userId) });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ success: false, error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/users/:userId', async (c) => {
|
||||||
|
const userId = c.req.param('userId');
|
||||||
|
const name = containerName(userId);
|
||||||
|
try {
|
||||||
|
if (await incus.exists(name)) {
|
||||||
|
await incus.stop(name);
|
||||||
|
// TODO: 备份 rootfs tar.gz 到 MinIO (下一轮实现)
|
||||||
|
await incus.remove(name);
|
||||||
|
}
|
||||||
|
state.markDeleted(userId);
|
||||||
|
return c.json({ success: true, userId });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ success: false, error: (e as Error).message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Tools ----
|
||||||
|
|
||||||
|
const ToolBody = z.object({
|
||||||
|
userId: z.string().min(1).max(64),
|
||||||
|
params: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/tools/:toolName', async (c) => {
|
||||||
|
const toolName = c.req.param('toolName');
|
||||||
|
const handler = handlers[toolName];
|
||||||
|
if (!handler) return c.json({ success: false, error: { message: `unknown tool: ${toolName}` } }, 404);
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const parsed = ToolBody.safeParse(body);
|
||||||
|
if (!parsed.success) return c.json({ success: false, error: { message: 'invalid body' } }, 400);
|
||||||
|
try {
|
||||||
|
const result = await handler(parsed.data.params, parsed.data.userId);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ success: false, error: { message: (e as Error).message } }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// exportFile 特殊路由(因为要流 S3,不塞 tools 里)
|
||||||
|
app.post('/api/v1/export-file', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
const parsed = z
|
||||||
|
.object({ userId: z.string(), path: z.string(), filename: z.string() })
|
||||||
|
.safeParse(body);
|
||||||
|
if (!parsed.success) return c.json({ success: false, error: 'invalid body' }, 400);
|
||||||
|
const r = await exportFile(parsed.data.userId, parsed.data.path, parsed.data.filename);
|
||||||
|
return c.json(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Boot ----
|
||||||
|
|
||||||
|
startIdleReaper();
|
||||||
|
|
||||||
|
console.log(`[orchestrator] listening on ${env.host}:${env.port}`);
|
||||||
|
console.log(`[orchestrator] incus project=${env.incus.project} image=${env.incus.baseImage}`);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port: env.port,
|
||||||
|
hostname: env.host,
|
||||||
|
fetch: app.fetch,
|
||||||
|
};
|
||||||
33
orchestrator/src/reaper.ts
Normal file
33
orchestrator/src/reaper.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { env } from './env.ts';
|
||||||
|
import { incus, containerName } from './incus.ts';
|
||||||
|
import { state } from './state.ts';
|
||||||
|
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
export const startIdleReaper = (): void => {
|
||||||
|
// 5 min 扫一次
|
||||||
|
setInterval(() => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
const idleUsers = state.findIdle(env.idleTimeoutMs);
|
||||||
|
for (const userId of idleUsers) {
|
||||||
|
const name = containerName(userId);
|
||||||
|
const s = await incus.state(name);
|
||||||
|
if (s === 'RUNNING') {
|
||||||
|
console.log(`[reaper] stopping idle sandbox for user ${userId}`);
|
||||||
|
try {
|
||||||
|
await incus.stop(name);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[reaper] stop ${name} failed:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.clearActivity(userId);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
running = false;
|
||||||
|
});
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
};
|
||||||
238
orchestrator/src/tools.ts
Normal file
238
orchestrator/src/tools.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { incus, containerName } from './incus.ts';
|
||||||
|
import { state } from './state.ts';
|
||||||
|
|
||||||
|
export interface ToolResult {
|
||||||
|
success: boolean;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message: string; name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (result: unknown): ToolResult => ({ success: true, result });
|
||||||
|
const fail = (message: string, name?: string): ToolResult => ({
|
||||||
|
success: false,
|
||||||
|
error: { message, name },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保容器跑着 — 调任何工具前必须调
|
||||||
|
const ensureRunning = async (userId: string): Promise<string> => {
|
||||||
|
const name = containerName(userId);
|
||||||
|
const s = await incus.state(name);
|
||||||
|
if (s === 'MISSING') throw new Error(`Sandbox not provisioned for user ${userId}`);
|
||||||
|
if (s === 'STOPPED') await incus.start(name);
|
||||||
|
state.touch(userId);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 临时路径生成(容器内)
|
||||||
|
const tmpPath = (ext: string) => `/tmp/sb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
|
||||||
|
|
||||||
|
// ==================== executeCode ====================
|
||||||
|
|
||||||
|
const LANG_RUNNER: Record<string, { ext: string; cmd: (p: string) => string[] }> = {
|
||||||
|
python: { ext: '.py', cmd: (p) => ['python3', p] },
|
||||||
|
javascript: { ext: '.js', cmd: (p) => ['node', p] },
|
||||||
|
typescript: { ext: '.ts', cmd: (p) => ['bun', 'run', p] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const executeCode = async (
|
||||||
|
params: { code: string; language: string; description?: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const runner = LANG_RUNNER[params.language];
|
||||||
|
if (!runner) return fail(`unsupported language: ${params.language}`);
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const path = tmpPath(runner.ext);
|
||||||
|
try {
|
||||||
|
await incus.push(name, path, params.code, { mode: '0644' });
|
||||||
|
await incus.exec(name, ['chown', 'sandbox:sandbox', path]);
|
||||||
|
const r = await incus.exec(name, runner.cmd(path), {
|
||||||
|
user: 1000,
|
||||||
|
cwd: '/workspace',
|
||||||
|
timeoutMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return ok({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
|
||||||
|
} finally {
|
||||||
|
await incus.exec(name, ['rm', '-f', path]).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== listLocalFiles ====================
|
||||||
|
|
||||||
|
export const listLocalFiles = async (
|
||||||
|
params: { directoryPath: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(
|
||||||
|
name,
|
||||||
|
['ls', '-la', '--time-style=long-iso', params.directoryPath],
|
||||||
|
{ user: 1000 },
|
||||||
|
);
|
||||||
|
if (r.exitCode !== 0) return fail(r.stderr || 'ls failed');
|
||||||
|
return ok({ output: r.stdout });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== readLocalFile ====================
|
||||||
|
|
||||||
|
export const readLocalFile = async (
|
||||||
|
params: { filePath: string; startLine?: number; endLine?: number },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const cmd =
|
||||||
|
params.startLine || params.endLine
|
||||||
|
? ['sed', '-n', `${params.startLine ?? 1},${params.endLine ?? '$'}p`, params.filePath]
|
||||||
|
: ['cat', params.filePath];
|
||||||
|
const r = await incus.exec(name, cmd, { user: 1000 });
|
||||||
|
if (r.exitCode !== 0) return fail(r.stderr || 'read failed');
|
||||||
|
return ok({ content: r.stdout });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== writeLocalFile ====================
|
||||||
|
|
||||||
|
export const writeLocalFile = async (
|
||||||
|
params: { filePath: string; content: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
await incus.push(name, params.filePath, params.content, { mode: '0644' });
|
||||||
|
await incus.exec(name, ['chown', 'sandbox:sandbox', params.filePath]);
|
||||||
|
return ok({ filePath: params.filePath, bytes: params.content.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== editLocalFile ====================
|
||||||
|
|
||||||
|
export const editLocalFile = async (
|
||||||
|
params: { filePath: string; oldContent: string; newContent: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(name, ['cat', params.filePath], { user: 1000 });
|
||||||
|
if (r.exitCode !== 0) return fail(`read ${params.filePath}: ${r.stderr}`);
|
||||||
|
const idx = r.stdout.indexOf(params.oldContent);
|
||||||
|
if (idx === -1) return fail('oldContent not found');
|
||||||
|
const nextIdx = r.stdout.indexOf(params.oldContent, idx + params.oldContent.length);
|
||||||
|
if (nextIdx !== -1) return fail('oldContent is ambiguous (multiple matches)');
|
||||||
|
const updated =
|
||||||
|
r.stdout.slice(0, idx) + params.newContent + r.stdout.slice(idx + params.oldContent.length);
|
||||||
|
await incus.push(name, params.filePath, updated, { mode: '0644' });
|
||||||
|
await incus.exec(name, ['chown', 'sandbox:sandbox', params.filePath]);
|
||||||
|
return ok({ filePath: params.filePath, replaced: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== runCommand (sync, with timeout) ====================
|
||||||
|
|
||||||
|
export const runCommand = async (
|
||||||
|
params: { command: string; cwd?: string; timeoutMs?: number },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(name, ['bash', '-lc', params.command], {
|
||||||
|
user: 1000,
|
||||||
|
cwd: params.cwd ?? '/workspace',
|
||||||
|
timeoutMs: params.timeoutMs ?? 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return ok({ stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
|
||||||
|
};
|
||||||
|
|
||||||
|
// getCommandOutput + killCommand - MVP synchronous runCommand 已返回完整输出,长驻命令留给 v2
|
||||||
|
export const getCommandOutput = async (): Promise<ToolResult> =>
|
||||||
|
fail('long-running commands not yet supported; runCommand is synchronous');
|
||||||
|
|
||||||
|
export const killCommand = async (): Promise<ToolResult> =>
|
||||||
|
fail('long-running commands not yet supported');
|
||||||
|
|
||||||
|
// ==================== searchLocalFiles (find by name) ====================
|
||||||
|
|
||||||
|
export const searchLocalFiles = async (
|
||||||
|
params: { directoryPath: string; pattern: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(
|
||||||
|
name,
|
||||||
|
['fd', '--no-ignore-vcs', params.pattern, params.directoryPath],
|
||||||
|
{ user: 1000, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
return ok({ matches: r.stdout.split('\n').filter(Boolean) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== grepContent ====================
|
||||||
|
|
||||||
|
export const grepContent = async (
|
||||||
|
params: { directoryPath: string; pattern: string; caseSensitive?: boolean },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const args = ['rg', '-n', '--no-ignore-vcs'];
|
||||||
|
if (!params.caseSensitive) args.push('-i');
|
||||||
|
args.push('--', params.pattern, params.directoryPath);
|
||||||
|
const r = await incus.exec(name, args, { user: 1000, timeoutMs: 30_000 });
|
||||||
|
return ok({ matches: r.stdout });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== globLocalFiles ====================
|
||||||
|
|
||||||
|
export const globLocalFiles = async (
|
||||||
|
params: { pattern: string; cwd?: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(
|
||||||
|
name,
|
||||||
|
['bash', '-lc', `shopt -s globstar nullglob dotglob; printf '%s\\n' ${JSON.stringify(params.pattern)}`],
|
||||||
|
{ user: 1000, cwd: params.cwd ?? '/workspace' },
|
||||||
|
);
|
||||||
|
return ok({ matches: r.stdout.split('\n').filter(Boolean) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== moveLocalFiles ====================
|
||||||
|
|
||||||
|
export const moveLocalFiles = async (
|
||||||
|
params: { sources: string[]; destination: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(name, ['mv', '--', ...params.sources, params.destination], { user: 1000 });
|
||||||
|
if (r.exitCode !== 0) return fail(r.stderr);
|
||||||
|
return ok({ moved: params.sources.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== renameLocalFile ====================
|
||||||
|
|
||||||
|
export const renameLocalFile = async (
|
||||||
|
params: { oldPath: string; newPath: string },
|
||||||
|
userId: string,
|
||||||
|
): Promise<ToolResult> => {
|
||||||
|
const name = await ensureRunning(userId);
|
||||||
|
const r = await incus.exec(name, ['mv', '--', params.oldPath, params.newPath], { user: 1000 });
|
||||||
|
if (r.exitCode !== 0) return fail(r.stderr);
|
||||||
|
return ok({ oldPath: params.oldPath, newPath: params.newPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== exportFile ====================
|
||||||
|
|
||||||
|
// exportFile 在 routes 里直接处理(要流式 pull → S3 upload)
|
||||||
|
export const exportFileStub = async (): Promise<ToolResult> =>
|
||||||
|
fail('exportFile must be routed to /api/v1/export-file, not /api/v1/tools/exportFile');
|
||||||
|
|
||||||
|
// ==================== Dispatcher ====================
|
||||||
|
|
||||||
|
type ToolHandler = (params: any, userId: string) => Promise<ToolResult>;
|
||||||
|
|
||||||
|
export const handlers: Record<string, ToolHandler> = {
|
||||||
|
executeCode,
|
||||||
|
listLocalFiles,
|
||||||
|
readLocalFile,
|
||||||
|
writeLocalFile,
|
||||||
|
editLocalFile,
|
||||||
|
runCommand,
|
||||||
|
getCommandOutput,
|
||||||
|
killCommand,
|
||||||
|
searchLocalFiles,
|
||||||
|
grepContent,
|
||||||
|
globLocalFiles,
|
||||||
|
moveLocalFiles,
|
||||||
|
renameLocalFile,
|
||||||
|
exportFile: exportFileStub,
|
||||||
|
};
|
||||||
70
scripts/deploy.sh
Executable file
70
scripts/deploy.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy orchestrator to VPS.
|
||||||
|
# Idempotent: safe to run for updates (just re-syncs code and restarts).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VPS="${VPS:-root@2.24.28.41}"
|
||||||
|
REMOTE_DIR="/opt/lobe-sandbox"
|
||||||
|
ENV_FILE="/etc/lobe-sandbox/orchestrator.env"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
log() { echo "==> $*"; }
|
||||||
|
|
||||||
|
log "1/6 Ensure Bun on VPS"
|
||||||
|
ssh "$VPS" '
|
||||||
|
command -v bun >/dev/null || {
|
||||||
|
curl -fsSL https://bun.sh/install | env BUN_INSTALL=/usr/local bash
|
||||||
|
}
|
||||||
|
bun --version
|
||||||
|
'
|
||||||
|
|
||||||
|
log "2/6 Ensure remote dirs"
|
||||||
|
ssh "$VPS" "mkdir -p $REMOTE_DIR /etc/lobe-sandbox /var/lib/lobe-sandbox"
|
||||||
|
|
||||||
|
log "3/6 Sync orchestrator source"
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude node_modules --exclude dist --exclude .env --exclude .env.local \
|
||||||
|
"$PROJECT_ROOT/orchestrator/" \
|
||||||
|
"$VPS:$REMOTE_DIR/orchestrator/"
|
||||||
|
|
||||||
|
log "4/6 bun install on VPS"
|
||||||
|
ssh "$VPS" "cd $REMOTE_DIR/orchestrator && bun install --production --frozen-lockfile 2>/dev/null || bun install --production"
|
||||||
|
|
||||||
|
log "5/6 Env file (only if missing — do not overwrite)"
|
||||||
|
ssh "$VPS" "
|
||||||
|
if [ ! -f $ENV_FILE ]; then
|
||||||
|
cat > $ENV_FILE <<'ENV'
|
||||||
|
PORT=8700
|
||||||
|
HOST=127.0.0.1
|
||||||
|
SANDBOX_ORCH_SECRET=CHANGEME_RUN_openssl_rand_base64_32
|
||||||
|
INCUS_PROJECT=lobe-sandbox
|
||||||
|
INCUS_PROFILE=sandbox-default
|
||||||
|
INCUS_BASE_IMAGE=lobe-sandbox-base
|
||||||
|
INCUS_CONTAINER_PREFIX=sb-
|
||||||
|
IDLE_TIMEOUT_MS=1800000
|
||||||
|
STATE_DB_PATH=/var/lib/lobe-sandbox/state.sqlite
|
||||||
|
S3_ENDPOINT=http://192.168.2.221:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=lobe-sandbox-exports
|
||||||
|
S3_ACCESS_KEY_ID=admin
|
||||||
|
S3_SECRET_ACCESS_KEY=CHANGEME
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
ENV
|
||||||
|
chmod 600 $ENV_FILE
|
||||||
|
echo '!!! First deploy. Edit $ENV_FILE before starting service !!!'
|
||||||
|
fi
|
||||||
|
"
|
||||||
|
|
||||||
|
log "6/6 Install systemd unit and reload"
|
||||||
|
scp "$PROJECT_ROOT/deploy/sandbox-orchestrator.service" \
|
||||||
|
"$VPS:/etc/systemd/system/sandbox-orchestrator.service"
|
||||||
|
ssh "$VPS" 'systemctl daemon-reload'
|
||||||
|
|
||||||
|
log "DONE"
|
||||||
|
log ""
|
||||||
|
log "Next: ssh $VPS and run:"
|
||||||
|
log " 1. nano $ENV_FILE # set SANDBOX_ORCH_SECRET + S3_SECRET_ACCESS_KEY"
|
||||||
|
log " 2. systemctl enable --now sandbox-orchestrator"
|
||||||
|
log " 3. journalctl -u sandbox-orchestrator -f"
|
||||||
|
log " 4. curl http://127.0.0.1:8700/health"
|
||||||
0
scripts/host-init.sh
Normal file → Executable file
0
scripts/host-init.sh
Normal file → Executable file
Reference in New Issue
Block a user