@xcanwin/manyoyo 5.9.3 → 5.10.3

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
@@ -34,7 +34,8 @@ const WEB_TERMINAL_MIN_ROWS = 12;
34
34
  const WEB_AGENT_CONTEXT_MAX_MESSAGES = 24;
35
35
  const WEB_AGENT_CONTEXT_MAX_CHARS = 6000;
36
36
  const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
37
- const WEB_FILE_PREVIEW_MAX_BYTES = 256 * 1024;
37
+ const WEB_FILE_PREVIEW_MAX_BYTES = 512 * 1024;
38
+ const WEB_FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
38
39
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
39
40
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
40
41
  const WEB_SESSION_KEY_SEPARATOR = '~';
@@ -2657,7 +2658,12 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
2657
2658
  const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
2658
2659
  const cleanOutputSource = extractedAgentMessage || clippedRaw;
2659
2660
  const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
2660
- resolve({ exitCode, output });
2661
+ resolve({
2662
+ exitCode,
2663
+ output,
2664
+ stdout: clippedStdout,
2665
+ stderr: clippedStderr
2666
+ });
2661
2667
  });
2662
2668
  });
2663
2669
  }
@@ -2679,7 +2685,7 @@ async function execJsonCommandInWebContainer(ctx, containerName, command) {
2679
2685
  throw new Error(result.output || '容器命令执行失败');
2680
2686
  }
2681
2687
  try {
2682
- return JSON.parse(String(result.output || '{}'));
2688
+ return JSON.parse(String(result.stdout || '{}'));
2683
2689
  } catch (e) {
2684
2690
  throw new Error('容器返回了无法解析的 JSON');
2685
2691
  }
@@ -2748,13 +2754,17 @@ try {
2748
2754
  `);
2749
2755
  }
2750
2756
 
2751
- function buildContainerFileReadCommand(requestedPath) {
2757
+ function buildContainerFileReadCommand(requestedPath, options = {}) {
2758
+ const opts = options && typeof options === 'object' ? options : {};
2759
+ const maxBytes = Number.isFinite(opts.maxBytes) && opts.maxBytes > 0
2760
+ ? Math.floor(opts.maxBytes)
2761
+ : 0;
2752
2762
  return buildWebContainerNodeCommand(`
2753
2763
  // __MANYOYO_FS_READ__
2754
2764
  const fs = require('fs');
2755
2765
 
2756
2766
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
2757
- const maxBytes = ${String(WEB_FILE_PREVIEW_MAX_BYTES)};
2767
+ const maxBytes = ${String(maxBytes)};
2758
2768
 
2759
2769
  function looksBinary(buffer) {
2760
2770
  const length = Math.min(buffer.length, 4096);
@@ -2779,7 +2789,7 @@ try {
2779
2789
  }
2780
2790
 
2781
2791
  const size = stat.size;
2782
- const readBytes = Math.min(size, maxBytes);
2792
+ const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
2783
2793
  const buffer = Buffer.alloc(readBytes);
2784
2794
  const fd = fs.openSync(realPath, 'r');
2785
2795
  try {
@@ -2793,14 +2803,14 @@ try {
2793
2803
  path: realPath,
2794
2804
  kind: 'binary',
2795
2805
  size,
2796
- truncated: size > maxBytes
2806
+ truncated: maxBytes > 0 && size > maxBytes
2797
2807
  }));
2798
2808
  } else {
2799
2809
  process.stdout.write(JSON.stringify({
2800
2810
  path: realPath,
2801
2811
  kind: 'text',
2802
2812
  size,
2803
- truncated: size > maxBytes,
2813
+ truncated: maxBytes > 0 && size > maxBytes,
2804
2814
  content: buffer.toString('utf8')
2805
2815
  }));
2806
2816
  }
@@ -2812,6 +2822,71 @@ try {
2812
2822
  `);
2813
2823
  }
2814
2824
 
2825
+ function buildContainerFileWriteCommand(requestedPath, content) {
2826
+ return buildWebContainerNodeCommand(`
2827
+ // __MANYOYO_FS_WRITE__
2828
+ const fs = require('fs');
2829
+
2830
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
2831
+ const nextContent = ${JSON.stringify(String(content == null ? '' : content))};
2832
+
2833
+ try {
2834
+ const realPath = fs.realpathSync(requestedPath);
2835
+ const stat = fs.statSync(realPath);
2836
+ if (!stat.isFile()) {
2837
+ throw new Error('目标不是文件: ' + realPath);
2838
+ }
2839
+
2840
+ fs.writeFileSync(realPath, nextContent, 'utf8');
2841
+ const savedStat = fs.statSync(realPath);
2842
+ process.stdout.write(JSON.stringify({
2843
+ path: realPath,
2844
+ saved: true,
2845
+ size: savedStat.size
2846
+ }));
2847
+ } catch (e) {
2848
+ process.stdout.write(JSON.stringify({
2849
+ error: e && e.message ? e.message : '保存文件失败'
2850
+ }));
2851
+ }
2852
+ `);
2853
+ }
2854
+
2855
+ function buildContainerFileMkdirCommand(requestedPath) {
2856
+ return buildWebContainerNodeCommand(`
2857
+ // __MANYOYO_FS_MKDIR__
2858
+ const fs = require('fs');
2859
+ const path = require('path');
2860
+
2861
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
2862
+
2863
+ try {
2864
+ const resolvedPath = path.resolve(requestedPath);
2865
+ const parentPath = path.dirname(resolvedPath);
2866
+ const realParentPath = fs.realpathSync(parentPath);
2867
+ const targetPath = path.join(realParentPath, path.basename(resolvedPath));
2868
+ if (fs.existsSync(targetPath)) {
2869
+ throw new Error('目录已存在: ' + targetPath);
2870
+ }
2871
+
2872
+ fs.mkdirSync(targetPath, { recursive: true });
2873
+ const stat = fs.statSync(targetPath);
2874
+ process.stdout.write(JSON.stringify({
2875
+ path: targetPath,
2876
+ name: path.basename(targetPath),
2877
+ kind: 'directory',
2878
+ size: 0,
2879
+ mtimeMs: stat.mtimeMs,
2880
+ created: true
2881
+ }));
2882
+ } catch (e) {
2883
+ process.stdout.write(JSON.stringify({
2884
+ error: e && e.message ? e.message : '创建目录失败'
2885
+ }));
2886
+ }
2887
+ `);
2888
+ }
2889
+
2815
2890
  async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
2816
2891
  const opts = options && typeof options === 'object' ? options : {};
2817
2892
  const sessionRef = typeof sessionRefOrContainerName === 'string'
@@ -3090,7 +3165,12 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
3090
3165
  defaultCommand: containerInfo.defaultCommand || ''
3091
3166
  });
3092
3167
  const createdAt = agentSession.createdAt || containerInfo.createdAt || null;
3093
- const updatedAt = agentSession.updatedAt || history.updatedAt || (latestMessage && latestMessage.timestamp) || containerInfo.createdAt || null;
3168
+ const updatedAt = agentSession.updatedAt
3169
+ || (latestMessage && latestMessage.timestamp)
3170
+ || (agentId === WEB_DEFAULT_AGENT_ID
3171
+ ? (history.updatedAt || containerInfo.createdAt)
3172
+ : null)
3173
+ || null;
3094
3174
  return {
3095
3175
  name: buildWebSessionKey(containerName, agentId),
3096
3176
  containerName,
@@ -3583,6 +3663,25 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3583
3663
  });
3584
3664
  }
3585
3665
  },
3666
+ {
3667
+ method: 'POST',
3668
+ match: currentPath => currentPath === '/api/fs/directories/mkdir' ? [] : null,
3669
+ handler: async () => {
3670
+ const payload = await readJsonBody(req);
3671
+ const requestedPath = expandHomeAliasPath(String(payload && payload.path ? payload.path : '').trim());
3672
+ if (!requestedPath) {
3673
+ sendJson(res, 400, { error: 'path 不能为空' });
3674
+ return;
3675
+ }
3676
+
3677
+ const targetPath = path.resolve(requestedPath);
3678
+ fs.mkdirSync(targetPath, { recursive: true });
3679
+ sendJson(res, 200, {
3680
+ path: targetPath,
3681
+ created: true
3682
+ });
3683
+ }
3684
+ },
3586
3685
  {
3587
3686
  method: 'GET',
3588
3687
  match: currentPath => currentPath === '/api/config' ? [] : null,
@@ -3742,6 +3841,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3742
3841
  }
3743
3842
  const requestUrl = new URL(req.url || '/api/sessions/x/fs/read', 'http://localhost');
3744
3843
  const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
3844
+ const fullRequested = ['1', 'true', 'yes'].includes(String(requestUrl.searchParams.get('full') || '').toLowerCase());
3745
3845
  if (!targetPath) {
3746
3846
  sendJson(res, 400, { error: 'path 不能为空' });
3747
3847
  return;
@@ -3751,7 +3851,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3751
3851
  const payload = await execJsonCommandInWebContainer(
3752
3852
  ctx,
3753
3853
  sessionRef.containerName,
3754
- buildContainerFileReadCommand(targetPath)
3854
+ buildContainerFileReadCommand(targetPath, {
3855
+ maxBytes: fullRequested ? 0 : WEB_FILE_PREVIEW_MAX_BYTES
3856
+ })
3755
3857
  );
3756
3858
  if (payload && payload.error) {
3757
3859
  sendJson(res, 400, { error: payload.error });
@@ -3759,10 +3861,77 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3759
3861
  }
3760
3862
  if (payload && payload.kind === 'text') {
3761
3863
  payload.language = inferFileLanguage(payload.path);
3864
+ payload.editable = payload.truncated !== true
3865
+ && Number(payload.size || 0) < WEB_FILE_EDIT_MAX_BYTES;
3762
3866
  }
3763
3867
  sendJson(res, 200, payload);
3764
3868
  }
3765
3869
  },
3870
+ {
3871
+ method: 'PUT',
3872
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/write$/),
3873
+ handler: async match => {
3874
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
3875
+ if (!sessionRef) {
3876
+ return;
3877
+ }
3878
+ const payload = await readJsonBody(req);
3879
+ const targetPath = String(payload && payload.path ? payload.path : '').trim();
3880
+ const content = typeof payload.content === 'string' ? payload.content : null;
3881
+ if (!targetPath) {
3882
+ sendJson(res, 400, { error: 'path 不能为空' });
3883
+ return;
3884
+ }
3885
+ if (content === null) {
3886
+ sendJson(res, 400, { error: 'content 必须是字符串' });
3887
+ return;
3888
+ }
3889
+ if (Buffer.byteLength(content, 'utf8') >= WEB_FILE_EDIT_MAX_BYTES) {
3890
+ sendJson(res, 400, { error: '文件过大,当前仅支持编辑小于 2MB 的文本文件' });
3891
+ return;
3892
+ }
3893
+
3894
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3895
+ const result = await execJsonCommandInWebContainer(
3896
+ ctx,
3897
+ sessionRef.containerName,
3898
+ buildContainerFileWriteCommand(targetPath, content)
3899
+ );
3900
+ if (result && result.error) {
3901
+ sendJson(res, 400, { error: result.error });
3902
+ return;
3903
+ }
3904
+ sendJson(res, 200, result);
3905
+ }
3906
+ },
3907
+ {
3908
+ method: 'POST',
3909
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/mkdir$/),
3910
+ handler: async match => {
3911
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
3912
+ if (!sessionRef) {
3913
+ return;
3914
+ }
3915
+ const payload = await readJsonBody(req);
3916
+ const targetPath = String(payload && payload.path ? payload.path : '').trim();
3917
+ if (!targetPath) {
3918
+ sendJson(res, 400, { error: 'path 不能为空' });
3919
+ return;
3920
+ }
3921
+
3922
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3923
+ const result = await execJsonCommandInWebContainer(
3924
+ ctx,
3925
+ sessionRef.containerName,
3926
+ buildContainerFileMkdirCommand(targetPath)
3927
+ );
3928
+ if (result && result.error) {
3929
+ sendJson(res, 400, { error: result.error });
3930
+ return;
3931
+ }
3932
+ sendJson(res, 200, result);
3933
+ }
3934
+ },
3766
3935
  {
3767
3936
  method: 'GET',
3768
3937
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/detail$/),
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ function defaultRunGitCommand(targetPath, args) {
8
+ const result = spawnSync('git', ['-C', targetPath, ...args], {
9
+ encoding: 'utf-8'
10
+ });
11
+
12
+ if (result.status !== 0) {
13
+ const stderr = String(result.stderr || '').trim();
14
+ throw new Error(stderr || `git ${args.join(' ')} 执行失败`);
15
+ }
16
+
17
+ return String(result.stdout || '').trim();
18
+ }
19
+
20
+ function normalizeAbsolutePath(targetPath) {
21
+ return path.resolve(String(targetPath || '').trim());
22
+ }
23
+
24
+ function isDescendantPath(parentPath, targetPath) {
25
+ const relativePath = path.relative(parentPath, targetPath);
26
+ return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
27
+ }
28
+
29
+ function detectGitWorktreeContext(targetPath, deps = {}) {
30
+ const fsApi = deps.fs || fs;
31
+ const pathApi = deps.path || path;
32
+ const runGitCommand = deps.runGitCommand || defaultRunGitCommand;
33
+ const absoluteTargetPath = normalizeAbsolutePath(targetPath);
34
+
35
+ if (!fsApi.existsSync(absoluteTargetPath)) {
36
+ throw new Error(`启用 --worktrees 时宿主机路径不存在: ${absoluteTargetPath}`);
37
+ }
38
+
39
+ const stats = fsApi.statSync(absoluteTargetPath);
40
+ if (!stats.isDirectory()) {
41
+ throw new Error(`启用 --worktrees 时宿主机路径必须为目录: ${absoluteTargetPath}`);
42
+ }
43
+
44
+ let repoRoot;
45
+ let commonDirRaw;
46
+ try {
47
+ repoRoot = pathApi.resolve(runGitCommand(absoluteTargetPath, ['rev-parse', '--show-toplevel']));
48
+ commonDirRaw = runGitCommand(absoluteTargetPath, ['rev-parse', '--git-common-dir']);
49
+ } catch (error) {
50
+ throw new Error(`启用 --worktrees 失败: ${absoluteTargetPath} 不在 Git 仓库内`);
51
+ }
52
+
53
+ const commonDir = pathApi.isAbsolute(commonDirRaw)
54
+ ? pathApi.normalize(commonDirRaw)
55
+ : pathApi.resolve(repoRoot, commonDirRaw);
56
+ const mainRepoRoot = pathApi.dirname(commonDir);
57
+ const projectName = pathApi.basename(mainRepoRoot);
58
+
59
+ return {
60
+ targetPath: absoluteTargetPath,
61
+ repoRoot,
62
+ mainRepoRoot,
63
+ isWorktree: repoRoot !== mainRepoRoot,
64
+ projectName,
65
+ defaultWorktreesRoot: pathApi.join(pathApi.dirname(mainRepoRoot), 'worktrees', projectName)
66
+ };
67
+ }
68
+
69
+ function shouldAddSamePathMount(hostPath, containerPath, targetPath) {
70
+ if (hostPath !== containerPath) {
71
+ return true;
72
+ }
73
+ return !isDescendantPath(hostPath, targetPath);
74
+ }
75
+
76
+ function resolveWorktreeSupport(options = {}, deps = {}) {
77
+ const fsApi = deps.fs || fs;
78
+ const pathApi = deps.path || path;
79
+ const enabled = options.enabled === true || Boolean(options.worktreesRoot);
80
+
81
+ if (!enabled) {
82
+ return {
83
+ enabled: false,
84
+ worktreesRoot: null,
85
+ worktreeRepoRoot: null,
86
+ worktreeMainRepoRoot: null,
87
+ extraVolumes: []
88
+ };
89
+ }
90
+
91
+ const hostPath = normalizeAbsolutePath(options.hostPath);
92
+ const containerPath = String(options.containerPath || hostPath).trim() || hostPath;
93
+ const detected = detectGitWorktreeContext(hostPath, deps);
94
+ let worktreesRoot = options.worktreesRoot;
95
+
96
+ if (worktreesRoot !== undefined && worktreesRoot !== null && String(worktreesRoot).trim() !== '') {
97
+ if (!pathApi.isAbsolute(worktreesRoot)) {
98
+ throw new Error(`--worktrees-root 仅支持绝对路径: ${worktreesRoot}`);
99
+ }
100
+ worktreesRoot = pathApi.resolve(worktreesRoot);
101
+ } else {
102
+ worktreesRoot = detected.defaultWorktreesRoot;
103
+ }
104
+
105
+ fsApi.mkdirSync(worktreesRoot, { recursive: true });
106
+
107
+ const existingVolumes = new Set((options.volumes || []).map(item => String(item)));
108
+ const extraVolumes = [];
109
+ [detected.mainRepoRoot, worktreesRoot].forEach(targetPath => {
110
+ if (!shouldAddSamePathMount(hostPath, containerPath, targetPath)) {
111
+ return;
112
+ }
113
+ const volume = `${targetPath}:${targetPath}`;
114
+ if (existingVolumes.has(volume) || extraVolumes.includes(volume)) {
115
+ return;
116
+ }
117
+ extraVolumes.push(volume);
118
+ });
119
+
120
+ return {
121
+ enabled: true,
122
+ worktreesRoot,
123
+ worktreeRepoRoot: detected.repoRoot,
124
+ worktreeMainRepoRoot: detected.mainRepoRoot,
125
+ extraVolumes
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ detectGitWorktreeContext,
131
+ resolveWorktreeSupport
132
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "5.9.3",
3
+ "version": "5.10.3",
4
4
  "imageVersion": "1.9.0-common",
5
5
  "playwrightCliVersion": "0.1.1",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
@@ -87,6 +87,7 @@
87
87
  "esbuild": "^0.25.12",
88
88
  "glob": "^13.0.6",
89
89
  "minimatch": "^10.2.2",
90
+ "postcss": "^8.5.10",
90
91
  "test-exclude": "^8.0.0",
91
92
  "vite": "^6.4.2"
92
93
  },