@xcanwin/manyoyo 6.2.5 → 6.2.7

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/lib/web/server.js CHANGED
@@ -45,6 +45,16 @@ const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
45
45
  const NATIVE_SESSION_RESUME_PROGRAMS = new Set(['claude', 'codex', 'opencode']);
46
46
  const WEB_FILE_PREVIEW_MAX_BYTES = 512 * 1024;
47
47
  const WEB_FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
48
+ const WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER = 4;
49
+ const IMAGE_EXTENSION_CONTENT_TYPES = {
50
+ '.png': 'image/png',
51
+ '.jpg': 'image/jpeg',
52
+ '.jpeg': 'image/jpeg',
53
+ '.gif': 'image/gif',
54
+ '.webp': 'image/webp',
55
+ '.bmp': 'image/bmp',
56
+ '.ico': 'image/x-icon'
57
+ };
48
58
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
49
59
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
50
60
  const WEB_SESSION_KEY_SEPARATOR = '~';
@@ -157,11 +167,21 @@ function normalizeWebAgentName(agentId, agentName) {
157
167
  return String(agentId || '').trim() || WEB_DEFAULT_AGENT_NAME;
158
168
  }
159
169
 
170
+ const WEB_REMARK_MAX_LENGTH = 200;
171
+
172
+ function normalizeRemarkText(value) {
173
+ if (typeof value !== 'string') {
174
+ return '';
175
+ }
176
+ return value.trim().slice(0, WEB_REMARK_MAX_LENGTH);
177
+ }
178
+
160
179
  function createEmptyWebAgentSession(agentId, agentName) {
161
180
  return {
162
181
  agentId,
163
182
  agentName: normalizeWebAgentName(agentId, agentName),
164
183
  agentPromptCommand: '',
184
+ remark: '',
165
185
  createdAt: null,
166
186
  updatedAt: null,
167
187
  messages: [],
@@ -181,6 +201,7 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
181
201
  agentPromptCommand: typeof source.agentPromptCommand === 'string'
182
202
  ? normalizeAgentPromptCommandTemplate(source.agentPromptCommand, `agents.${agentId}.agentPromptCommand`)
183
203
  : '',
204
+ remark: normalizeRemarkText(source.remark),
184
205
  createdAt: typeof source.createdAt === 'string' ? source.createdAt : null,
185
206
  updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : null,
186
207
  messages: Array.isArray(source.messages) ? source.messages : [],
@@ -244,6 +265,9 @@ function normalizeWebHistoryRecord(containerName, rawData) {
244
265
  const applied = data.applied && typeof data.applied === 'object' && !Array.isArray(data.applied)
245
266
  ? data.applied
246
267
  : null;
268
+ const runtimeSnapshot = data.runtimeSnapshot && typeof data.runtimeSnapshot === 'object' && !Array.isArray(data.runtimeSnapshot)
269
+ ? data.runtimeSnapshot
270
+ : null;
247
271
  const history = {
248
272
  containerName,
249
273
  updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : null,
@@ -251,7 +275,10 @@ function normalizeWebHistoryRecord(containerName, rawData) {
251
275
  typeof data.agentPromptCommand === 'string' ? data.agentPromptCommand : '',
252
276
  applied
253
277
  ),
278
+ remark: normalizeRemarkText(data.remark),
254
279
  applied,
280
+ // 服务端内部字段,绝不能被 buildSessionSummary/buildSessionDetail/buildSessionAudit 引用或透传给前端。
281
+ runtimeSnapshot,
255
282
  agents: {}
256
283
  };
257
284
 
@@ -2547,6 +2574,24 @@ function buildCreateRuntime(ctx, state, payload) {
2547
2574
  containerPorts,
2548
2575
  agentPromptCommand,
2549
2576
  defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
2577
+ // 仅供服务端内部复用(如"创建相同配置容器"/"复制容器"),绝不能透传给任何对外响应字段,
2578
+ // 因为 env/volumes/ports 可能包含密钥原始值(对外响应向来只暴露 envCount 等计数)。
2579
+ runtimeSnapshot: {
2580
+ hostPath,
2581
+ containerPath,
2582
+ imageName,
2583
+ imageVersion,
2584
+ containerMode,
2585
+ shellPrefix: shellPrefix || '',
2586
+ shell: shell || '',
2587
+ shellSuffix: shellSuffix || '',
2588
+ agentPromptCommand: configuredAgentPromptCommand,
2589
+ yolo: yolo || '',
2590
+ env: resolvedBase.env || {},
2591
+ envFile: resolvedBase.envFile || [],
2592
+ volumes: resolvedBase.volumes || [],
2593
+ ports: resolvedBase.ports || []
2594
+ },
2550
2595
  applied: {
2551
2596
  containerName,
2552
2597
  hostPath,
@@ -2569,6 +2614,138 @@ function buildCreateRuntime(ctx, state, payload) {
2569
2614
  };
2570
2615
  }
2571
2616
 
2617
+ function resolveUniqueContainerName(ctx, state, baseName) {
2618
+ const containerMap = listWebManyoyoContainers(ctx);
2619
+ const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
2620
+ const taken = new Set([...Object.keys(containerMap), ...historyNames]);
2621
+ // 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
2622
+ // 避免"删除 -copy1 后新副本又叫 -copy1"造成的名称语义混乱。
2623
+ const escapedBase = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2624
+ const pattern = new RegExp(`^${escapedBase}-copy(\\d+)$`);
2625
+ let maxN = 0;
2626
+ taken.forEach(name => {
2627
+ const matched = name.match(pattern);
2628
+ if (matched) {
2629
+ const n = Number(matched[1]);
2630
+ if (Number.isFinite(n) && n > maxN) {
2631
+ maxN = n;
2632
+ }
2633
+ }
2634
+ });
2635
+ return `${baseName}-copy${maxN + 1}`;
2636
+ }
2637
+
2638
+ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
2639
+ if (runtimeSnapshot && typeof runtimeSnapshot === 'object') {
2640
+ return {
2641
+ hostPath: runtimeSnapshot.hostPath || '',
2642
+ containerPath: runtimeSnapshot.containerPath || '',
2643
+ imageName: runtimeSnapshot.imageName || '',
2644
+ imageVersion: runtimeSnapshot.imageVersion || '',
2645
+ containerMode: runtimeSnapshot.containerMode || '',
2646
+ shellPrefix: runtimeSnapshot.shellPrefix || '',
2647
+ shell: runtimeSnapshot.shell || '',
2648
+ shellSuffix: runtimeSnapshot.shellSuffix || '',
2649
+ agentPromptCommand: runtimeSnapshot.agentPromptCommand || '',
2650
+ yolo: runtimeSnapshot.yolo || '',
2651
+ env: runtimeSnapshot.env || {},
2652
+ envFile: runtimeSnapshot.envFile || [],
2653
+ volumes: runtimeSnapshot.volumes || [],
2654
+ ports: runtimeSnapshot.ports || []
2655
+ };
2656
+ }
2657
+
2658
+ const applied = fallbackHistory && fallbackHistory.applied && typeof fallbackHistory.applied === 'object'
2659
+ ? fallbackHistory.applied
2660
+ : {};
2661
+ return {
2662
+ hostPath: applied.hostPath || '',
2663
+ containerPath: applied.containerPath || '',
2664
+ imageName: applied.imageName || '',
2665
+ imageVersion: applied.imageVersion || '',
2666
+ containerMode: applied.containerMode || '',
2667
+ shellPrefix: applied.shellPrefix || '',
2668
+ shell: applied.shell || '',
2669
+ shellSuffix: applied.shellSuffix || '',
2670
+ agentPromptCommand: typeof fallbackHistory.agentPromptCommand === 'string' ? fallbackHistory.agentPromptCommand : '',
2671
+ yolo: applied.yolo || '',
2672
+ env: {},
2673
+ envFile: [],
2674
+ volumes: [],
2675
+ ports: []
2676
+ };
2677
+ }
2678
+
2679
+ function cloneWebAgentSessionsDeep(sourceHistory) {
2680
+ const cloned = {};
2681
+ const agents = sourceHistory && sourceHistory.agents && typeof sourceHistory.agents === 'object'
2682
+ ? sourceHistory.agents
2683
+ : {};
2684
+ Object.keys(agents).forEach(agentId => {
2685
+ cloned[agentId] = JSON.parse(JSON.stringify(agents[agentId]));
2686
+ });
2687
+ return cloned;
2688
+ }
2689
+
2690
+ async function createClonedContainer(ctx, state, sourceContainerName, requestedName, options = {}) {
2691
+ const copyHistory = options.copyHistory === true;
2692
+ const history = loadWebSessionHistory(state.webHistoryDir, sourceContainerName);
2693
+ const cloneFidelity = history.runtimeSnapshot ? 'full' : 'partial';
2694
+ const createOptions = buildCloneCreateOptionsFromSnapshot(history.runtimeSnapshot, history);
2695
+
2696
+ let finalName = '';
2697
+ if (requestedName) {
2698
+ validateContainerNameStrict(requestedName);
2699
+ const containerMap = listWebManyoyoContainers(ctx);
2700
+ const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
2701
+ const taken = new Set([...Object.keys(containerMap), ...historyNames]);
2702
+ if (taken.has(requestedName)) {
2703
+ const err = new Error(`容器名已存在: ${requestedName}`);
2704
+ err.statusCode = 409;
2705
+ throw err;
2706
+ }
2707
+ finalName = requestedName;
2708
+ } else {
2709
+ finalName = resolveUniqueContainerName(ctx, state, sourceContainerName);
2710
+ }
2711
+
2712
+ createOptions.containerName = finalName;
2713
+ const runtime = buildCreateRuntime(ctx, state, { createOptions });
2714
+ await ensureWebContainer(ctx, state, runtime);
2715
+ setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
2716
+
2717
+ const patch = {
2718
+ applied: runtime.applied,
2719
+ runtimeSnapshot: runtime.runtimeSnapshot
2720
+ };
2721
+ if (copyHistory) {
2722
+ patch.agents = cloneWebAgentSessionsDeep(history);
2723
+ patch.agentPromptCommand = history.agentPromptCommand;
2724
+ }
2725
+ patchWebSessionHistory(state.webHistoryDir, finalName, patch);
2726
+
2727
+ return {
2728
+ name: finalName,
2729
+ applied: runtime.applied,
2730
+ sourceContainerName,
2731
+ cloneFidelity,
2732
+ resumeMayFail: copyHistory
2733
+ };
2734
+ }
2735
+
2736
+ function resolveContainerNamesByHostPath(ctx, state, hostPath) {
2737
+ const target = String(hostPath || '').trim();
2738
+ const containerMap = listWebManyoyoContainers(ctx);
2739
+ const names = new Set([
2740
+ ...Object.keys(containerMap),
2741
+ ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
2742
+ ]);
2743
+ return Array.from(names).filter(name => {
2744
+ const summary = buildSessionSummary(ctx, state, containerMap, { containerName: name, agentId: WEB_DEFAULT_AGENT_ID });
2745
+ return summary && String(summary.hostPath || '').trim() === target;
2746
+ });
2747
+ }
2748
+
2572
2749
  // Estimate container start time from "Up X hours/minutes/seconds" status string.
2573
2750
  // Uses relative time to avoid Podman Machine VM clock drift issues.
2574
2751
  function estimateStartTimeFromStatus(status) {
@@ -2763,6 +2940,34 @@ ${scriptSource}
2763
2940
  __MANYOYO_NODE__`;
2764
2941
  }
2765
2942
 
2943
+ function spawnContainerRawFileStream(ctx, containerName, filePath) {
2944
+ return spawn(
2945
+ ctx.dockerCmd,
2946
+ ['exec', containerName, 'cat', '--', filePath],
2947
+ { stdio: ['ignore', 'pipe', 'pipe'] }
2948
+ );
2949
+ }
2950
+
2951
+ const webRawFileStreamCounts = new Map();
2952
+
2953
+ function acquireWebRawFileStreamSlot(containerName) {
2954
+ const current = webRawFileStreamCounts.get(containerName) || 0;
2955
+ if (current >= WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER) {
2956
+ return false;
2957
+ }
2958
+ webRawFileStreamCounts.set(containerName, current + 1);
2959
+ return true;
2960
+ }
2961
+
2962
+ function releaseWebRawFileStreamSlot(containerName) {
2963
+ const current = webRawFileStreamCounts.get(containerName) || 0;
2964
+ if (current <= 1) {
2965
+ webRawFileStreamCounts.delete(containerName);
2966
+ return;
2967
+ }
2968
+ webRawFileStreamCounts.set(containerName, current - 1);
2969
+ }
2970
+
2766
2971
  function inferFileLanguage(filePath) {
2767
2972
  const ext = path.extname(String(filePath || '')).toLowerCase();
2768
2973
  return FILE_LANGUAGE_MAP[ext] || 'text';
@@ -2851,9 +3056,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
2851
3056
  return buildWebContainerNodeCommand(`
2852
3057
  // __MANYOYO_FS_READ__
2853
3058
  const fs = require('fs');
3059
+ const path = require('path');
2854
3060
 
2855
3061
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
2856
3062
  const maxBytes = ${String(maxBytes)};
3063
+ const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
2857
3064
 
2858
3065
  function looksBinary(buffer) {
2859
3066
  const length = Math.min(buffer.length, 4096);
@@ -2877,31 +3084,40 @@ try {
2877
3084
  throw new Error('目标不是文件: ' + realPath);
2878
3085
  }
2879
3086
 
2880
- const size = stat.size;
2881
- const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
2882
- const buffer = Buffer.alloc(readBytes);
2883
- const fd = fs.openSync(realPath, 'r');
2884
- try {
2885
- fs.readSync(fd, buffer, 0, readBytes, 0);
2886
- } finally {
2887
- fs.closeSync(fd);
2888
- }
2889
-
2890
- if (looksBinary(buffer)) {
3087
+ // 图片走 fs/raw 流式传输,这里只确认元信息,不把整份二进制读进内存/JSON
3088
+ if (imageExtensions.has(path.extname(realPath).toLowerCase())) {
2891
3089
  process.stdout.write(JSON.stringify({
2892
3090
  path: realPath,
2893
- kind: 'binary',
2894
- size,
2895
- truncated: maxBytes > 0 && size > maxBytes
3091
+ kind: 'image',
3092
+ size: stat.size
2896
3093
  }));
2897
3094
  } else {
2898
- process.stdout.write(JSON.stringify({
2899
- path: realPath,
2900
- kind: 'text',
2901
- size,
2902
- truncated: maxBytes > 0 && size > maxBytes,
2903
- content: buffer.toString('utf8')
2904
- }));
3095
+ const size = stat.size;
3096
+ const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
3097
+ const buffer = Buffer.alloc(readBytes);
3098
+ const fd = fs.openSync(realPath, 'r');
3099
+ try {
3100
+ fs.readSync(fd, buffer, 0, readBytes, 0);
3101
+ } finally {
3102
+ fs.closeSync(fd);
3103
+ }
3104
+
3105
+ if (looksBinary(buffer)) {
3106
+ process.stdout.write(JSON.stringify({
3107
+ path: realPath,
3108
+ kind: 'binary',
3109
+ size,
3110
+ truncated: maxBytes > 0 && size > maxBytes
3111
+ }));
3112
+ } else {
3113
+ process.stdout.write(JSON.stringify({
3114
+ path: realPath,
3115
+ kind: 'text',
3116
+ size,
3117
+ truncated: maxBytes > 0 && size > maxBytes,
3118
+ content: buffer.toString('utf8')
3119
+ }));
3120
+ }
2905
3121
  }
2906
3122
  } catch (e) {
2907
3123
  process.stdout.write(JSON.stringify({
@@ -2911,6 +3127,31 @@ try {
2911
3127
  `);
2912
3128
  }
2913
3129
 
3130
+ function buildContainerFileStatCommand(requestedPath) {
3131
+ return buildWebContainerNodeCommand(`
3132
+ // __MANYOYO_FS_STAT__
3133
+ const fs = require('fs');
3134
+
3135
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3136
+
3137
+ try {
3138
+ const realPath = fs.realpathSync(requestedPath);
3139
+ const stat = fs.statSync(realPath);
3140
+ if (!stat.isFile()) {
3141
+ throw new Error('目标不是文件: ' + realPath);
3142
+ }
3143
+ process.stdout.write(JSON.stringify({
3144
+ path: realPath,
3145
+ size: stat.size
3146
+ }));
3147
+ } catch (e) {
3148
+ process.stdout.write(JSON.stringify({
3149
+ error: e && e.message ? e.message : '读取文件失败'
3150
+ }));
3151
+ }
3152
+ `);
3153
+ }
3154
+
2914
3155
  function buildContainerFileWriteCommand(requestedPath, content) {
2915
3156
  return buildWebContainerNodeCommand(`
2916
3157
  // __MANYOYO_FS_WRITE__
@@ -3283,6 +3524,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
3283
3524
  containerName,
3284
3525
  agentId,
3285
3526
  agentName: agentSession.agentName,
3527
+ agentRemark: agentSession.remark || '',
3528
+ containerRemark: history.remark || '',
3286
3529
  status: containerInfo.status || 'history',
3287
3530
  image: containerInfo.image || '',
3288
3531
  createdAt,
@@ -3924,7 +4167,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3924
4167
  await ensureWebContainer(ctx, state, runtime);
3925
4168
  setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
3926
4169
  patchWebSessionHistory(state.webHistoryDir, runtime.containerName, {
3927
- applied: runtime.applied
4170
+ applied: runtime.applied,
4171
+ runtimeSnapshot: runtime.runtimeSnapshot
3928
4172
  });
3929
4173
  sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
3930
4174
  }
@@ -3948,6 +4192,125 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3948
4192
  });
3949
4193
  }
3950
4194
  },
4195
+ {
4196
+ method: 'POST',
4197
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/clone-config$/),
4198
+ handler: async match => {
4199
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4200
+ if (!sessionRef) {
4201
+ return;
4202
+ }
4203
+ if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
4204
+ sendJson(res, 400, { error: '该操作需在容器层级执行' });
4205
+ return;
4206
+ }
4207
+ const sourceContainerName = sessionRef.containerName;
4208
+ const historyExists = fs.existsSync(getWebHistoryFile(state.webHistoryDir, sourceContainerName));
4209
+ if (!historyExists && !ctx.containerExists(sourceContainerName)) {
4210
+ sendJson(res, 404, { error: `容器不存在: ${sourceContainerName}` });
4211
+ return;
4212
+ }
4213
+ const payload = await readJsonBody(req);
4214
+ const requestedName = pickFirstString(payload.containerName);
4215
+ try {
4216
+ const result = await createClonedContainer(ctx, state, sourceContainerName, requestedName, { copyHistory: false });
4217
+ sendJson(res, 200, result);
4218
+ } catch (e) {
4219
+ sendJson(res, e.statusCode || 400, { error: e.message || '创建相同配置容器失败' });
4220
+ }
4221
+ }
4222
+ },
4223
+ {
4224
+ method: 'POST',
4225
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/duplicate$/),
4226
+ handler: async match => {
4227
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4228
+ if (!sessionRef) {
4229
+ return;
4230
+ }
4231
+ if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
4232
+ sendJson(res, 400, { error: '该操作需在容器层级执行' });
4233
+ return;
4234
+ }
4235
+ const sourceContainerName = sessionRef.containerName;
4236
+ const historyExists = fs.existsSync(getWebHistoryFile(state.webHistoryDir, sourceContainerName));
4237
+ if (!historyExists && !ctx.containerExists(sourceContainerName)) {
4238
+ sendJson(res, 404, { error: `容器不存在: ${sourceContainerName}` });
4239
+ return;
4240
+ }
4241
+ const payload = await readJsonBody(req);
4242
+ const requestedName = pickFirstString(payload.containerName);
4243
+ try {
4244
+ const result = await createClonedContainer(ctx, state, sourceContainerName, requestedName, { copyHistory: true });
4245
+ sendJson(res, 200, result);
4246
+ } catch (e) {
4247
+ sendJson(res, e.statusCode || 400, { error: e.message || '复制容器失败' });
4248
+ }
4249
+ }
4250
+ },
4251
+ {
4252
+ method: 'POST',
4253
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/container-remark$/),
4254
+ handler: async match => {
4255
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4256
+ if (!sessionRef) {
4257
+ return;
4258
+ }
4259
+ if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
4260
+ sendJson(res, 400, { error: '该操作需在容器层级执行' });
4261
+ return;
4262
+ }
4263
+ const payload = await readJsonBody(req);
4264
+ const remark = normalizeRemarkText(payload.remark);
4265
+ patchWebSessionHistory(state.webHistoryDir, sessionRef.containerName, { remark });
4266
+ sendJson(res, 200, { containerName: sessionRef.containerName, remark });
4267
+ }
4268
+ },
4269
+ {
4270
+ method: 'POST',
4271
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-remark$/),
4272
+ handler: async match => {
4273
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4274
+ if (!sessionRef) {
4275
+ return;
4276
+ }
4277
+ const payload = await readJsonBody(req);
4278
+ const remark = normalizeRemarkText(payload.remark);
4279
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { remark });
4280
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), remark });
4281
+ }
4282
+ },
4283
+ {
4284
+ method: 'POST',
4285
+ match: currentPath => currentPath === '/api/sessions/by-directory/remove' ? [] : null,
4286
+ handler: async () => {
4287
+ const payload = await readJsonBody(req);
4288
+ const hostPath = typeof payload.hostPath === 'string' ? payload.hostPath : '';
4289
+ if (!hostPath.trim()) {
4290
+ sendJson(res, 400, { error: 'hostPath 不能为空' });
4291
+ return;
4292
+ }
4293
+ const targets = resolveContainerNamesByHostPath(ctx, state, hostPath);
4294
+ if (!targets.length) {
4295
+ sendJson(res, 404, { error: '未找到该工作目录下的容器' });
4296
+ return;
4297
+ }
4298
+ const removed = [];
4299
+ const errors = [];
4300
+ targets.forEach(name => {
4301
+ try {
4302
+ if (ctx.containerExists(name)) {
4303
+ ctx.removeContainer(name);
4304
+ }
4305
+ removeWebSessionHistory(state.webHistoryDir, name);
4306
+ removed.push(name);
4307
+ } catch (e) {
4308
+ errors.push({ name, error: e.message || String(e) });
4309
+ }
4310
+ });
4311
+ sendJson(res, 200, { hostPath, removed, errors });
4312
+ }
4313
+ },
3951
4314
  {
3952
4315
  method: 'GET',
3953
4316
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
@@ -4027,6 +4390,73 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4027
4390
  sendJson(res, 200, payload);
4028
4391
  }
4029
4392
  },
4393
+ {
4394
+ method: 'GET',
4395
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/raw$/),
4396
+ handler: async match => {
4397
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4398
+ if (!sessionRef) {
4399
+ return;
4400
+ }
4401
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/raw', 'http://localhost');
4402
+ const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
4403
+ if (!targetPath) {
4404
+ sendJson(res, 400, { error: 'path 不能为空' });
4405
+ return;
4406
+ }
4407
+ const ext = path.extname(targetPath).toLowerCase();
4408
+ const contentType = IMAGE_EXTENSION_CONTENT_TYPES[ext];
4409
+ if (!contentType) {
4410
+ sendJson(res, 400, { error: '不支持预览该文件类型' });
4411
+ return;
4412
+ }
4413
+
4414
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
4415
+ const statPayload = await execJsonCommandInWebContainer(
4416
+ ctx,
4417
+ sessionRef.containerName,
4418
+ buildContainerFileStatCommand(targetPath)
4419
+ );
4420
+ if (statPayload && statPayload.error) {
4421
+ sendJson(res, 404, { error: statPayload.error });
4422
+ return;
4423
+ }
4424
+
4425
+ if (!acquireWebRawFileStreamSlot(sessionRef.containerName)) {
4426
+ sendJson(res, 429, { error: '预览并发已达上限,请稍后重试' });
4427
+ return;
4428
+ }
4429
+
4430
+ let released = false;
4431
+ const releaseOnce = () => {
4432
+ if (released) {
4433
+ return;
4434
+ }
4435
+ released = true;
4436
+ releaseWebRawFileStreamSlot(sessionRef.containerName);
4437
+ };
4438
+
4439
+ const child = spawnContainerRawFileStream(ctx, sessionRef.containerName, statPayload.path);
4440
+ // stderr 单独消费,绝不混入响应体,避免管道缓冲区打满导致 cat 进程卡死
4441
+ child.stderr.on('data', () => {});
4442
+ child.on('error', () => releaseOnce());
4443
+ child.on('close', () => releaseOnce());
4444
+ res.on('close', () => {
4445
+ releaseOnce();
4446
+ if (!child.killed) {
4447
+ child.kill();
4448
+ }
4449
+ });
4450
+
4451
+ res.writeHead(200, {
4452
+ 'Content-Type': contentType,
4453
+ 'Content-Length': String(statPayload.size),
4454
+ 'X-Content-Type-Options': 'nosniff',
4455
+ 'Cache-Control': 'no-store'
4456
+ });
4457
+ child.stdout.pipe(res);
4458
+ }
4459
+ },
4030
4460
  {
4031
4461
  method: 'PUT',
4032
4462
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/write$/),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "6.2.5",
3
+ "version": "6.2.7",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",