@xcanwin/manyoyo 8.6.9 → 8.6.11

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/bin/manyoyo.js CHANGED
@@ -26,6 +26,7 @@ const { listServeInstances, formatServeTable } = require('../lib/serve-instances
26
26
  const { pruneDanglingImages: pruneDanglingImagesSafely } = require('../lib/image-prune');
27
27
  const { runUninstall, readPid, defaultIsManyoyoServe, defaultKill } = require('../lib/uninstall');
28
28
  const appUpdate = require('../lib/app-update');
29
+ const codeUpdate = require('../lib/code-update');
29
30
  const { getGithubFetch } = require('../lib/net-proxy');
30
31
  const { createUpdateChecker } = require('../lib/update-check');
31
32
  const { getLoginTokenDir, issueLoginToken } = require('../lib/login-token');
@@ -348,7 +349,7 @@ function installServeProcessDiagnostics(logger) {
348
349
  * @property {string[]} [volumes] - 挂载卷数组
349
350
  * @property {Object.<string, Object>} [plugins] - 可选插件配置映射(如 plugins.playwright)
350
351
  * @property {{title?:string}} [serve] - serve 命令专属配置(如自定义网页标题,留空字符串则显示为空)
351
- * @property {string} [agentInstructions] - 网页里执行 Agent 时追加给它的说明(接在内置环境说明后面;runs.<name> 可覆盖)
352
+ * @property {string} [agentInstructions] - 补充系统提示词(接在默认系统提示词后面,作为网页新建容器的初始内容;runs.<name> 可覆盖)
352
353
  * @property {Object.<string, Object>} [runs] - 运行配置映射(-r <name>)
353
354
  * @property {string} [yolo] - YOLO 模式
354
355
  * @property {string} [containerMode] - 容器模式
@@ -862,7 +863,36 @@ async function updateOfflineInstall(mode, options, globalConfig) {
862
863
  const installed = appUpdate.currentVersion(appRoot) || mode.version;
863
864
  console.log(`${CYAN}🔄 当前版本: ${installed}${NC}`);
864
865
  let result;
865
- if (options.file) {
866
+ if (options.code) {
867
+ const github = getGithubFetch();
868
+ if (github.notice) console.log(`${CYAN}🌐 ${github.notice}${NC}`);
869
+ console.log(`${CYAN}🔄 正在查询分支 ${options.code} 的代码版...${NC}`);
870
+ try {
871
+ // MANYOYO_TEST_CODE_BASE 只给测试用(指向本地下载站替身)
872
+ const outcome = await codeUpdate.installCodeUpdate({
873
+ appRoot, branch: options.code, installed, fetchImpl: github.fetch, targetOs: appUpdate.platformOs(),
874
+ downloadBase: process.env.MANYOYO_TEST_CODE_BASE || undefined, log: line => console.log(` ${line}`),
875
+ // 校验通过后、下载或切换之前先说清楚装的是哪个提交
876
+ onInfo: info => console.log(`${CYAN} ${options.code}@${info.commit.slice(0, 7)} ${info.subject}(构建于 ${codeUpdate.formatBuiltAt(info.builtAt)})${NC}`)
877
+ });
878
+ const label = `${options.code}@${outcome.info.commit.slice(0, 7)}`;
879
+ if (outcome.status === 'same') {
880
+ console.log(`${GREEN}✅ 已是 ${label},无需更新${NC}`);
881
+ return;
882
+ }
883
+ if (outcome.status === 'same-release') {
884
+ console.log(`${GREEN}✅ ${label} 就是当前正式版 ${installed} 的代码,无需更新${NC}`);
885
+ return;
886
+ }
887
+ if (outcome.status === 'reused') console.log(`${CYAN} 使用本地已有的 ${outcome.version},无需下载${NC}`);
888
+ result = outcome;
889
+ } catch (error) {
890
+ if (error instanceof appUpdate.UpdateError && error.code === 'NETWORK') {
891
+ error.message += `\n ${github.proxy && github.proxy.proxyEnv ? `当前代理: ${github.proxy.display},请确认代理可用` : '有代理的话先在终端设置: export https_proxy=http://127.0.0.1:7890'}`;
892
+ }
893
+ throw error;
894
+ }
895
+ } else if (options.file) {
866
896
  const info = appUpdate.parseAppFileName(options.file);
867
897
  if (info && appUpdate.compareVersions(info.version, installed) <= 0) {
868
898
  console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
@@ -878,7 +908,7 @@ async function updateOfflineInstall(mode, options, globalConfig) {
878
908
  console.log(`${CYAN}🔄 正在查询最新版本...${NC}`);
879
909
  release = await appUpdate.fetchLatestRelease({ fetchImpl: github.fetch });
880
910
  if (appUpdate.compareVersions(release.version, installed) <= 0) {
881
- console.log(`${GREEN}✅ 已是最新版本 ${installed}${NC}`);
911
+ console.log(`${GREEN}✅ ${codeUpdate.latestReleaseNotice(installed, release.version)}${NC}`);
882
912
  return;
883
913
  }
884
914
  console.log(`${CYAN}⬇️ 发现新版本 ${release.version},只下载 manyoyo 本体(数十 MB)${NC}`);
@@ -938,6 +968,14 @@ async function updateOfflineInstall(mode, options, globalConfig) {
938
968
  }
939
969
 
940
970
  async function updateManyoyo(options = {}, globalConfig = {}) {
971
+ if (options.code && (options.file || options.rollback)) {
972
+ console.error(`${RED}❌ --code 不能同时使用 --file 或 --rollback。${NC}`);
973
+ process.exit(1);
974
+ }
975
+ if (options.code && !codeUpdate.isValidBranch(options.code)) {
976
+ console.error(`${RED}❌ 分支名不合法: ${String(options.code).slice(0, 60)}(只能用字母、数字、. _ - /,不能以符号开头,不能含 -- / .. / //)${NC}`);
977
+ process.exit(1);
978
+ }
941
979
  const mode = appUpdate.detectInstallMode({ scriptPath: __filename });
942
980
  if (mode.mode === 'offline') {
943
981
  try {
@@ -955,6 +993,10 @@ async function updateManyoyo(options = {}, globalConfig = {}) {
955
993
  console.error(`${RED}❌ --rollback 只适用于离线包安装;npm 安装的版本请用 npm install -g @xcanwin/manyoyo@<版本> 回退。${NC}`);
956
994
  process.exit(1);
957
995
  }
996
+ if (options.code) {
997
+ console.error(`${RED}❌ --code 只适用于离线包安装;npm 安装请用 npm install -g @xcanwin/manyoyo 升级。${NC}`);
998
+ process.exit(1);
999
+ }
958
1000
  if (options.file) {
959
1001
  console.error(`${RED}❌ --file 只适用于离线包安装;npm 安装请用 npm install -g @xcanwin/manyoyo 升级。${NC}`);
960
1002
  process.exit(1);
@@ -1225,7 +1267,8 @@ https://github.com/xcanwin/manyoyo
1225
1267
  .description('升级到最新版本')
1226
1268
  .option('--rollback', '回到上一版本')
1227
1269
  .option('--file <path>', '安装手动下载的升级包(manyoyo-<版本>-<系统>-<架构>-app.tar.gz,同目录需有 SHA256SUMS)')
1228
- .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback), file: options.file || '' }));
1270
+ .option('--code [branch]', '安装某个分支的最新代码版(未发布,默认 main)')
1271
+ .action(options => selectAction('update', { update: true, rollback: Boolean(options.rollback), file: options.file || '', code: options.code === undefined ? '' : (options.code === true ? 'main' : options.code) }));
1229
1272
 
1230
1273
  program.command('uninstall')
1231
1274
  .helpGroup('日常:')
@@ -1,14 +1,16 @@
1
1
  'use strict';
2
2
 
3
- // Web 端执行 Agent 时追加给它的环境说明(唯一数据源)。
3
+ // Web 端执行 Agent 时追加给它的系统提示词(唯一数据源)。
4
4
  // 只写环境事实、用条件句:不鼓励它主动生成或展示图片,也不重复模型本来就知道的事。
5
5
  const BUILTIN_AGENT_INSTRUCTIONS = [
6
- '你的回复会显示在 MANYOYO 网页里(用户可能在手机上看),Markdown 会被渲染。',
6
+ '你的回复会显示在网页里(用户可能在手机上看),Markdown 会被渲染。',
7
7
  '- 需要让用户看某张图片时(如截图、生成的图表),用 Markdown 图片语法写容器内的绝对路径:![说明](/路径/图.png),支持 png/jpg/jpeg/gif/webp/bmp/ico。没有必要时不要专门生成或展示图片。',
8
8
  '- 提到文件时写容器内的绝对路径,用户可以点开查看。'
9
9
  ].join('\n');
10
10
 
11
11
  const USER_INSTRUCTIONS_MAX_CHARS = 4000;
12
+ // 容器的系统提示词 = 默认 + 补充,上限放宽
13
+ const SYSTEM_PROMPT_MAX_CHARS = 8000;
12
14
 
13
15
  function pickUserInstructions(config, runName) {
14
16
  const source = config && typeof config === 'object' ? config : {};
@@ -22,18 +24,37 @@ function pickUserInstructions(config, runName) {
22
24
 
23
25
  // 去掉控制字符(保留换行、制表符):codex 的 TOML 字符串不接受它们,也没有正当用途;
24
26
  // {prompt} 会被命令模板当成占位符替换,拆开写
25
- function sanitizeUserInstructions(text) {
27
+ function sanitizeUserInstructions(text, max = USER_INSTRUCTIONS_MAX_CHARS) {
26
28
  return String(text || '')
27
29
  .replace(/\r\n?/g, '\n')
28
30
  .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
29
31
  .replace(/\{prompt\}/g, '{ prompt }')
30
32
  .trim()
31
- .slice(0, USER_INSTRUCTIONS_MAX_CHARS);
33
+ .slice(0, max);
32
34
  }
33
35
 
34
- function buildAgentInstructions(config, runName) {
35
- const extra = sanitizeUserInstructions(pickUserInstructions(config, runName));
36
- return extra ? `${BUILTIN_AGENT_INSTRUCTIONS}\n\n${extra}` : BUILTIN_AGENT_INSTRUCTIONS;
36
+ // 系统设置里两段各有开关(默认开):默认系统提示词(内置)与补充系统提示词(agentInstructions)
37
+ function pickEnabled(config, key) {
38
+ return !(config && typeof config === 'object' && config[key] === false);
39
+ }
40
+
41
+ // 默认系统提示词 + 补充系统提示词(按开关取用),新建容器时作为初始内容
42
+ function composeSystemPrompt(config, runName) {
43
+ const parts = [];
44
+ if (pickEnabled(config, 'agentInstructionsBuiltinEnabled')) parts.push(BUILTIN_AGENT_INSTRUCTIONS);
45
+ const extra = pickEnabled(config, 'agentInstructionsCustomEnabled')
46
+ ? sanitizeUserInstructions(pickUserInstructions(config, runName))
47
+ : '';
48
+ if (extra) parts.push(extra);
49
+ return parts.length ? `${parts.join('\n\n')}\n` : '';
50
+ }
51
+
52
+ // 某个容器实际使用的系统提示词:容器自己存过就以它为准(enabled=false 即不注入),没存过(旧容器)用当前全局设置
53
+ function buildAgentInstructions(config, runName, containerPrompt) {
54
+ if (containerPrompt && typeof containerPrompt === 'object') {
55
+ return containerPrompt.enabled === false ? '' : sanitizeUserInstructions(containerPrompt.text, SYSTEM_PROMPT_MAX_CHARS);
56
+ }
57
+ return sanitizeUserInstructions(composeSystemPrompt(config, runName), SYSTEM_PROMPT_MAX_CHARS);
37
58
  }
38
59
 
39
60
  // TOML 基本字符串:JSON 的转义(\" \\ \n \t \uXXXX)在 TOML 里含义相同;
@@ -45,6 +66,8 @@ function toTomlBasicString(text) {
45
66
  module.exports = {
46
67
  BUILTIN_AGENT_INSTRUCTIONS,
47
68
  buildAgentInstructions,
69
+ composeSystemPrompt,
48
70
  sanitizeUserInstructions,
71
+ SYSTEM_PROMPT_MAX_CHARS,
49
72
  toTomlBasicString
50
73
  };
package/lib/app-update.js CHANGED
@@ -15,6 +15,12 @@ const WEB_BASE = 'https://github.com';
15
15
  // 不依赖 PATH 查找 tar(macOS 与常见 Linux 都在 /usr/bin)
16
16
  const TAR_COMMAND = fs.existsSync('/usr/bin/tar') ? '/usr/bin/tar' : 'tar';
17
17
  const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
18
+ // app/ 下的目录名:正式版 x.y.z,或代码版 x.y.z+<提交前 7 位>(见 lib/code-update.js)
19
+ const APP_VERSION_PATTERN = /^\d+\.\d+\.\d+(\+[0-9a-f]{7})?$/;
20
+
21
+ function isAppVersion(value) {
22
+ return typeof value === 'string' && APP_VERSION_PATTERN.test(value);
23
+ }
18
24
  const FETCH_TIMEOUT_MS = 15000;
19
25
 
20
26
  class UpdateError extends Error {
@@ -71,7 +77,7 @@ function detectInstallMode({ scriptPath, homeDir = os.homedir() }) {
71
77
  const relative = path.relative(appRoot, real);
72
78
  if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
73
79
  const version = relative.split(path.sep)[0];
74
- if (VERSION_PATTERN.test(version)) {
80
+ if (isAppVersion(version)) {
75
81
  return { mode: 'offline', appRoot, version };
76
82
  }
77
83
  }
@@ -190,14 +196,29 @@ function readUpdateRecord(appRoot) {
190
196
  }
191
197
  }
192
198
 
193
- // 只保留当前与上一版本,其它版本目录与残留的临时目录一起清掉
199
+ // 只保留当前、上一版本和最新的正式版(连装几次代码版后仍能回到正式版),其它版本目录与残留的临时目录一起清掉
194
200
  function pruneApps(appRoot, keep) {
195
- for (const name of fs.readdirSync(appRoot)) {
196
- if (keep.includes(name) || name === 'current' || name === '.update.json') continue;
197
- if (VERSION_PATTERN.test(name) || name.startsWith('.tmp-')) fs.rmSync(path.join(appRoot, name), { recursive: true, force: true });
201
+ const names = fs.readdirSync(appRoot);
202
+ const latestRelease = names.filter(name => VERSION_PATTERN.test(name)).sort((a, b) => compareVersions(b, a))[0];
203
+ for (const name of names) {
204
+ if (keep.includes(name) || name === latestRelease || name === 'current' || name === '.update.json') continue;
205
+ if (isAppVersion(name) || name.startsWith('.tmp-')) fs.rmSync(path.join(appRoot, name), { recursive: true, force: true });
198
206
  }
199
207
  }
200
208
 
209
+ /**
210
+ * 切换 current 到 app/<version>/(目录必须已就位)、写 .update.json(记下上一版本)、清理旧版本。
211
+ * installFromArchive 与代码版的“本地已有就直接切换”共用;manifest 原样放进返回值,供调用方做收尾。
212
+ */
213
+ function switchInstalled({ appRoot, version, manifest }) {
214
+ const previous = currentVersion(appRoot);
215
+ switchCurrent(appRoot, version);
216
+ writeUpdateRecord(appRoot, { previous: previous && previous !== version ? previous : readUpdateRecord(appRoot).previous || '', current: version, updatedAt: new Date().toISOString() });
217
+ const record = readUpdateRecord(appRoot);
218
+ pruneApps(appRoot, [version, record.previous].filter(Boolean));
219
+ return { version, previous: record.previous || '', manifest };
220
+ }
221
+
201
222
  /**
202
223
  * 解开已校验过的 -app.tar.gz 并切换 current(网络下载与 --file 共用)。包不完整/内容不符都在切换前中止。
203
224
  */
@@ -226,11 +247,7 @@ async function installFromArchive({ appRoot, archive, version, targetArch, targe
226
247
  const target = path.join(appRoot, version);
227
248
  if (version !== previous) fs.rmSync(target, { recursive: true, force: true });
228
249
  if (version !== previous) fs.renameSync(staging, target);
229
- switchCurrent(appRoot, version);
230
- writeUpdateRecord(appRoot, { previous: previous && previous !== version ? previous : readUpdateRecord(appRoot).previous || '', current: version, updatedAt: new Date().toISOString() });
231
- const record = readUpdateRecord(appRoot);
232
- pruneApps(appRoot, [version, record.previous].filter(Boolean));
233
- return { version, previous: record.previous || '', manifest };
250
+ return switchInstalled({ appRoot, version, manifest });
234
251
  } finally {
235
252
  fs.rmSync(staging, { recursive: true, force: true });
236
253
  }
@@ -306,9 +323,10 @@ function rollbackApp({ appRoot }) {
306
323
  const current = currentVersion(appRoot);
307
324
  const record = readUpdateRecord(appRoot);
308
325
  let previous = record.previous;
309
- if (!previous || !VERSION_PATTERN.test(previous) || !fs.existsSync(path.join(appRoot, previous))) {
310
- const candidates = fs.existsSync(appRoot) ? fs.readdirSync(appRoot).filter(name => VERSION_PATTERN.test(name) && name !== current && fs.existsSync(path.join(appRoot, name, 'manyoyo', 'bin', 'manyoyo.js'))) : [];
311
- candidates.sort((a, b) => compareVersions(b, a));
326
+ if (!previous || !isAppVersion(previous) || !fs.existsSync(path.join(appRoot, previous))) {
327
+ const candidates = fs.existsSync(appRoot) ? fs.readdirSync(appRoot).filter(name => isAppVersion(name) && name !== current && fs.existsSync(path.join(appRoot, name, 'manyoyo', 'bin', 'manyoyo.js'))) : [];
328
+ // 版本相同(正式版与代码版)时正式版排前面
329
+ candidates.sort((a, b) => compareVersions(b, a) || (VERSION_PATTERN.test(b) ? 1 : 0) - (VERSION_PATTERN.test(a) ? 1 : 0));
312
330
  previous = candidates[0];
313
331
  }
314
332
  if (!previous) throw new UpdateError('NO_PREVIOUS', '没有可回滚的上一版本。');
@@ -386,6 +404,9 @@ module.exports = {
386
404
  UpdateError,
387
405
  parseVersion,
388
406
  compareVersions,
407
+ isAppVersion,
408
+ installFromArchive,
409
+ switchInstalled,
389
410
  detectInstallMode,
390
411
  fetchLatestRelease,
391
412
  parseSha256Sums,
@@ -394,6 +415,7 @@ module.exports = {
394
415
  installAppFile,
395
416
  parseAppFileName,
396
417
  networkError,
418
+ request,
397
419
  rollbackApp,
398
420
  currentVersion,
399
421
  describeRuntimeChange,
@@ -0,0 +1,212 @@
1
+ 'use strict';
2
+
3
+ // 代码版升级:`manyoyo update --code [分支]`。
4
+ // 目标机器没有 git / npm,所以构建在 CI 里做(.github/workflows/code-packages.yml、scripts/offline/build-code.js),
5
+ // 这里只下载 CODE.json → 校验 → 下载本机平台的 -app.tar.gz → SHA256 校验 → 复用 installFromArchive 切换 current。
6
+ // 网络与副作用都从参数注入。CODE.json 来自网络,每个字段都严格校验:资产名要“等于”按提交与平台拼出的名字,不是匹配某个正则。
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const appUpdate = require('./app-update');
12
+ const { downloadVerified, sha256File } = require('./download-verified');
13
+
14
+ const { UpdateError, compareVersions, networkError, request, installFromArchive, switchInstalled } = appUpdate;
15
+
16
+ const DOWNLOAD_BASE = 'https://github.com/xcanwin/manyoyo/releases/download/';
17
+ const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/;
18
+ const IMAGE_VERSION_PATTERN = /^\d+\.\d+\.\d+-[A-Za-z0-9][A-Za-z0-9_.-]*$/;
19
+ const PLATFORMS = ['linux-x64', 'linux-arm64', 'macos-x64', 'macos-arm64'];
20
+ const MAX_CODE_JSON_CHARS = 64 * 1024;
21
+ const SUBJECT_MAX_CHARS = 200;
22
+
23
+ function isValidBranch(branch) {
24
+ return typeof branch === 'string' && BRANCH_PATTERN.test(branch) && !branch.includes('--') && !branch.includes('..') && !branch.includes('//') && !branch.endsWith('/');
25
+ }
26
+
27
+ /** 分支 → 滚动预发布的 tag:code-<分支>,/ 换成 --(分支名不允许含 --,所以映射不会冲突)。CI 脚本与客户端共用。 */
28
+ function codeTagForBranch(branch) {
29
+ if (!isValidBranch(branch)) {
30
+ throw new UpdateError('BAD_BRANCH', `分支名不合法: ${String(branch).slice(0, 60)}(只能用字母、数字、. _ - /,不能以符号开头,不能含 -- / .. / //)`);
31
+ }
32
+ return `code-${branch.replace(/\//g, '--')}`;
33
+ }
34
+
35
+ function codeAssetName(commit, targetOs, targetArch) {
36
+ return `manyoyo-code-${String(commit).slice(0, 7)}-${targetOs}-${targetArch}-app.tar.gz`;
37
+ }
38
+
39
+ function bad(message) {
40
+ return new UpdateError('BAD_CODE_INFO', `代码版信息(CODE.json)不合法:${message},已中止。`);
41
+ }
42
+
43
+ /** 校验 CODE.json;返回规范化后的副本(subject 已清洗)。 */
44
+ function validateCodeInfo(info, { branch }) {
45
+ if (!info || typeof info !== 'object' || Array.isArray(info)) throw bad('不是对象');
46
+ if (info.schemaVersion !== 1) throw bad('schemaVersion 不支持');
47
+ if (info.branch !== branch) throw bad(`分支不符(期望 ${branch})`);
48
+ if (typeof info.commit !== 'string' || !/^[0-9a-f]{40}$/.test(info.commit)) throw bad('commit 格式不对');
49
+ if (typeof info.version !== 'string' || !/^\d+\.\d+\.\d+\+[0-9a-f]{7}$/.test(info.version) || !info.version.endsWith(`+${info.commit.slice(0, 7)}`)) throw bad('version 与 commit 不符');
50
+ if (typeof info.imageVersion !== 'string' || !IMAGE_VERSION_PATTERN.test(info.imageVersion)) throw bad('imageVersion 格式不对');
51
+ if (typeof info.imageReady !== 'boolean') throw bad('imageReady 必须是布尔值');
52
+ if (!info.assets || typeof info.assets !== 'object' || Array.isArray(info.assets)) throw bad('缺少 assets');
53
+ const assets = {};
54
+ for (const [key, asset] of Object.entries(info.assets)) {
55
+ if (!PLATFORMS.includes(key)) throw bad(`未知平台 ${key.slice(0, 40)}`);
56
+ const [targetOs, targetArch] = key.split('-');
57
+ if (!asset || typeof asset !== 'object') throw bad(`${key} 条目不是对象`);
58
+ if (asset.name !== codeAssetName(info.commit, targetOs, targetArch)) throw bad(`${key} 的文件名不符合命名规则`);
59
+ if (typeof asset.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(asset.sha256)) throw bad(`${key} 的 sha256 格式不对`);
60
+ if (!Number.isInteger(asset.size) || asset.size <= 0) throw bad(`${key} 的 size 不对`);
61
+ assets[key] = { name: asset.name, sha256: asset.sha256, size: asset.size };
62
+ }
63
+ // 只用于打印:去掉控制字符并截断
64
+ const subject = String(info.subject || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, SUBJECT_MAX_CHARS);
65
+ return {
66
+ schemaVersion: 1, branch, commit: info.commit, subject, version: info.version,
67
+ imageVersion: info.imageVersion, imageReady: info.imageReady, builtAt: typeof info.builtAt === 'string' ? info.builtAt.slice(0, 40) : '', assets
68
+ };
69
+ }
70
+
71
+ function tagBase(downloadBase, branch) {
72
+ return `${downloadBase}${encodeURIComponent(codeTagForBranch(branch))}/`;
73
+ }
74
+
75
+ // notFoundRetryMs:CI 替换 CODE.json 时(gh 的 --clobber 是先删后传)有一小段时间它不存在,404 先等一下再确认一次
76
+ async function fetchCodeInfo({ branch, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs = 2000 }) {
77
+ const url = `${tagBase(downloadBase, branch)}CODE.json`;
78
+ let response = await request(fetchImpl, url, { headers: { Accept: 'application/json' } });
79
+ if (response.status === 404) {
80
+ await new Promise(resolve => setTimeout(resolve, notFoundRetryMs));
81
+ response = await request(fetchImpl, url, { headers: { Accept: 'application/json' } });
82
+ }
83
+ if (response.status === 404) {
84
+ throw new UpdateError('NO_CODE_BUILD', `分支 ${branch} 还没有代码版(也可能正在更新)。main 分支在推送后会自动构建(需要几分钟,稍后再试);其他分支需要维护者手动触发构建。`);
85
+ }
86
+ if (!response.ok) throw new UpdateError('HTTP', `查询代码版信息失败(HTTP ${response.status})。\n 地址: ${url}`, { url });
87
+ // 声明的长度就超限的,不读内容
88
+ if (Number(response.headers.get('content-length')) > MAX_CODE_JSON_CHARS) throw bad('内容过大');
89
+ let text;
90
+ try {
91
+ text = await response.text();
92
+ } catch (error) {
93
+ throw networkError(url, error, '读取中断,连不上');
94
+ }
95
+ if (text.length > MAX_CODE_JSON_CHARS) throw bad('内容过大');
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ } catch (error) {
100
+ throw bad('不是合法的 JSON');
101
+ }
102
+ return validateCodeInfo(parsed, { branch });
103
+ }
104
+
105
+ // 当前版本的 manyoyo/package.json 里的 gitHead(正式包与代码版包构建时写入);读不到或格式不对当作没有
106
+ function readCurrentGitHead(appRoot) {
107
+ try {
108
+ const head = JSON.parse(fs.readFileSync(path.join(appRoot, 'current', 'manyoyo', 'package.json'), 'utf-8')).gitHead;
109
+ return typeof head === 'string' && /^[0-9a-f]{40}$/.test(head) ? head : '';
110
+ } catch (error) {
111
+ return '';
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 本地 app/<版本>/ 已经是这个代码版时返回它的 manifest,否则 null(调用方照常下载,不报错)。
117
+ * --rollback 直接切到本地目录,所以这里的检查不能比它宽松:目录结构完整,并额外核对 manifest 与 .installed、提交号。
118
+ */
119
+ async function findLocalCopy({ appRoot, info, targetArch, targetOs }) {
120
+ const dir = path.join(appRoot, info.version);
121
+ try {
122
+ for (const required of ['node/bin/node', 'manyoyo/bin/manyoyo.js', 'manifest.json', '.installed']) {
123
+ if (!fs.existsSync(path.join(dir, required))) return null;
124
+ }
125
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8'));
126
+ if (!manifest || manifest.version !== info.version || manifest.kind !== 'app' || manifest.arch !== targetArch || manifest.os !== targetOs) return null;
127
+ if (!manifest.source || manifest.source.commit !== info.commit) return null;
128
+ // installFromArchive 安装时把 manifest.json 的哈希写进 .installed:对得上才说明目录没被动过
129
+ if (fs.readFileSync(path.join(dir, '.installed'), 'utf-8').trim() !== await sha256File(path.join(dir, 'manifest.json'))) return null;
130
+ return manifest;
131
+ } catch (error) {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ // 构建时间按本机时区显示成 YYYY-MM-DD HH:mm;timeZone 可注入(测试不依赖运行机器的时区)
137
+ function formatBuiltAt(builtAt, timeZone) {
138
+ const date = new Date(builtAt);
139
+ if (typeof builtAt !== 'string' || !builtAt || Number.isNaN(date.getTime())) return '未知时间';
140
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
141
+ .formatToParts(date).map(part => [part.type, part.value]));
142
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
143
+ }
144
+
145
+ /**
146
+ * 安装某个分支的代码版。installed 是当前版本号。
147
+ * 返回:{ status: 'same', info }(已是这个版本)、{ status: 'same-release', info }(与当前正式版同一个提交)、
148
+ * { status: 'reused' | 'installed', info, version, previous, manifest }(本地已有就直接切换 / 下载安装)。
149
+ * 校验失败 / 镜像未发布 / 版本更旧都在下载与切换之前中止,current 不变。
150
+ * onInfo(info):全部校验通过后、下载或切换之前调用,让调用方先告诉用户装的是哪个提交。
151
+ */
152
+ async function installCodeUpdate({ appRoot, branch, installed, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs, onInfo, targetArch = appUpdate.arch(), targetOs = appUpdate.platformOs(), run, log = () => {}, tmpRoot = os.tmpdir() }) {
153
+ const info = await fetchCodeInfo({ branch, fetchImpl, downloadBase, notFoundRetryMs });
154
+ if (info.version === installed) return { status: 'same', info };
155
+ if (readCurrentGitHead(appRoot) === info.commit) return { status: 'same-release', info };
156
+ if (compareVersions(info.version, installed) < 0) {
157
+ throw new UpdateError('OLDER', `分支 ${branch} 的代码版是 ${info.version},比当前的 ${installed} 旧。${branch} 的最新提交可能还在构建,稍后再试;要换回正式版请用 manyoyo update --rollback 或重新运行安装命令。`);
158
+ }
159
+ if (!info.imageReady) {
160
+ throw new UpdateError('IMAGE_NOT_READY', `这个代码版需要镜像 ${info.imageVersion},但它还没有发布,现在升级会让容器拉不到镜像。请等镜像发布后再试,或换一个分支。`);
161
+ }
162
+ // 降级与镜像检查都排在本地复用之前,否则本地留着旧目录时会绕过它们
163
+ const local = await findLocalCopy({ appRoot, info, targetArch, targetOs });
164
+ const asset = info.assets[`${targetOs}-${targetArch}`];
165
+ if (!local && !asset) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包。`);
166
+ if (typeof onInfo === 'function') onInfo(info);
167
+ if (local) return { status: 'reused', info, ...switchInstalled({ appRoot, version: info.version, manifest: local }) };
168
+ const url = `${tagBase(downloadBase, branch)}${asset.name}`;
169
+ // 包是按 CODE.json 里的固定命名拼出来的:先确认确实存在(404 就是没有)
170
+ const probe = await request(fetchImpl, url, { method: 'HEAD' });
171
+ if (probe.status === 404) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 的升级包 ${asset.name} 还没有上传完整,请稍后再试。`);
172
+
173
+ const workDir = fs.mkdtempSync(path.join(tmpRoot, 'manyoyo-code-update-'));
174
+ try {
175
+ const archive = path.join(workDir, asset.name);
176
+ try {
177
+ // 服务端声明的大小与 CODE.json 不符就直接中止,不把磁盘写满再校验哈希
178
+ const guarded = async (target, options) => {
179
+ const response = await request(fetchImpl, target, { ...options, noTimeout: true });
180
+ const declared = response.headers.get('content-length');
181
+ if (response.ok && declared !== null && Number(declared) !== asset.size) {
182
+ // status 让 downloadVerified 当作明确的拒绝,不做重试
183
+ throw new UpdateError('CHECKSUM', `代码版升级包大小与 CODE.json 不符(${declared} ≠ ${asset.size}),已中止升级,当前版本不受影响。`, { status: response.status });
184
+ }
185
+ return response;
186
+ };
187
+ await downloadVerified({ url, sha256: asset.sha256, dest: archive, fetchImpl: guarded, log });
188
+ } catch (error) {
189
+ if (/SHA256 校验失败/.test(error.message)) throw new UpdateError('CHECKSUM', '代码版升级包 SHA256 校验失败,已中止升级,当前版本不受影响。');
190
+ if (error instanceof UpdateError) throw error;
191
+ throw networkError(url, error, '下载中断,连不上');
192
+ }
193
+ const result = await installFromArchive({ appRoot, archive, version: info.version, targetArch, targetOs, run });
194
+ return { status: 'installed', info, ...result };
195
+ } finally {
196
+ fs.rmSync(workDir, { recursive: true, force: true });
197
+ }
198
+ }
199
+
200
+ function isCodeVersion(version) {
201
+ return typeof version === 'string' && /^\d+\.\d+\.\d+\+[0-9a-f]{7}$/.test(version);
202
+ }
203
+
204
+ /** 普通 `update` 发现最新正式版不比当前新时说的话:代码版要特别说明怎么换回正式版。 */
205
+ function latestReleaseNotice(installed, latest) {
206
+ if (isCodeVersion(installed)) {
207
+ return `当前是代码版 ${installed},最新正式版 ${latest} 不比它新。要换回正式版,请重新运行安装命令(或 manyoyo update --rollback 回到上一个版本)。`;
208
+ }
209
+ return `已是最新版本 ${installed}`;
210
+ }
211
+
212
+ module.exports = { formatBuiltAt, isCodeVersion, latestReleaseNotice, DOWNLOAD_BASE, isValidBranch, codeTagForBranch, codeAssetName, validateCodeInfo, fetchCodeInfo, installCodeUpdate };
@@ -48,7 +48,8 @@ function paths(homeDir, id) {
48
48
  envFiles: path.join(dir, 'env-files.json'),
49
49
  network: path.join(dir, 'network.json'),
50
50
  netStatus: path.join(dir, 'net-status.json'),
51
- meta: path.join(dir, 'meta.json')
51
+ meta: path.join(dir, 'meta.json'),
52
+ systemPrompt: path.join(dir, 'system-prompt.json')
52
53
  };
53
54
  }
54
55
 
@@ -153,6 +154,7 @@ function writeFileAtomic(filePath, data, mode = 0o600) {
153
154
  * @param {string} [options.id]
154
155
  * @param {string[]} [options.envLines] KEY=VALUE(已校验)
155
156
  * @param {string} [options.autostart] 自启动脚本
157
+ * @param {{enabled: boolean, text: string}} [options.systemPrompt] 系统提示词
156
158
  * @param {object} [options.meta]
157
159
  * @param {boolean} [options.netRequired]
158
160
  * @param {object} [options.network] 已校验的网络策略(见 lib/network-policy.js)
@@ -178,6 +180,7 @@ function createState(options) {
178
180
  if (!fs.existsSync(p.managedEnv)) writeFileAtomic(p.managedEnv, '');
179
181
  setNetRequired(homeDir, id, Boolean(options.netRequired));
180
182
  if (options.network) writeNetworkRaw(homeDir, id, options.network);
183
+ if (options.systemPrompt) writeFileAtomic(p.systemPrompt, `${JSON.stringify({ enabled: options.systemPrompt.enabled !== false, text: String(options.systemPrompt.text || '') })}\n`);
181
184
  writeMeta(homeDir, id, { id, createdAt: new Date().toISOString(), ...(options.meta || {}) });
182
185
  return { id, ...p };
183
186
  }
@@ -398,6 +401,21 @@ function writeNetStatus(homeDir, id, status) {
398
401
  writeFileAtomic(paths(homeDir, id).netStatus, `${JSON.stringify(status)}\n`);
399
402
  }
400
403
 
404
+ // 容器自己的系统提示词 {enabled, text}:创建时定下,之后只能在容器设置里改;没有文件(旧容器)返回 null
405
+ function readSystemPrompt(homeDir, id) {
406
+ try {
407
+ const data = JSON.parse(fs.readFileSync(paths(homeDir, id).systemPrompt, 'utf-8'));
408
+ return { enabled: data.enabled !== false, text: typeof data.text === 'string' ? data.text : '' };
409
+ } catch (e) {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ function writeSystemPrompt(homeDir, id, prompt) {
415
+ assertStateExists(homeDir, id);
416
+ writeFileAtomic(paths(homeDir, id).systemPrompt, `${JSON.stringify({ enabled: prompt.enabled !== false, text: String(prompt.text || '') })}\n`);
417
+ }
418
+
401
419
  function removeState(homeDir, id) {
402
420
  if (!isValidId(id)) return false;
403
421
  fs.rmSync(stateDir(homeDir, id), { recursive: true, force: true });
@@ -456,6 +474,8 @@ module.exports = {
456
474
  readAutostartLogTail,
457
475
  writeManagedEnv,
458
476
  readMeta,
477
+ readSystemPrompt,
478
+ writeSystemPrompt,
459
479
  writeMeta,
460
480
  readNetworkRaw,
461
481
  writeNetworkRaw,
@@ -34,4 +34,8 @@ function buildAgentInstructionsConfigRaw(baseRaw, text) {
34
34
  return upsertValueByPath(baseRaw, ['agentInstructions'], JSON.stringify(String(text || '')));
35
35
  }
36
36
 
37
- module.exports = { EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };
37
+ function buildBooleanConfigRaw(baseRaw, key, value) {
38
+ return upsertValueByPath(baseRaw, [key], value ? 'true' : 'false');
39
+ }
40
+
41
+ module.exports = { buildBooleanConfigRaw, EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };
package/lib/web/AGENTS.md CHANGED
@@ -30,7 +30,7 @@
30
30
  - **终端 WebSocket**:`/api/sessions/:name/terminal/ws`,在 `server.on('upgrade')` 里做 Origin 校验 + 认证 + 容器名校验,再交给 `bindTerminalWebSocket()`。上行 `input` / `resize` / `ping` / `close`,下行 `output` / `status`(`ready` / `closed`)/ `error` / `pong`;实现是 `docker exec` 里用 `script` 或 python `pty` 引导出的伪 TTY。**动态 resize**:bootstrap 的 rc 把 `tty` 输出写进容器里 `/tmp/manyoyo-term-<随机id>.tty`;收到 `resize` 后(整数、10~500 列、4~200 行,否则整条忽略;下限比建连初值 40×12 低,因为手机竖屏不到 40 列;节流,同一时刻只有一个 exec)用固定脚本 `TERMINAL_RESIZE_SCRIPT` 在容器里读该文件,内容必须整行匹配 `/dev/pts/<数字>` 才执行 `stty -F`。**这个 tty 文件容器里的进程可写,只能当不可信输入**(脚本只认普通文件、不跟符号链接、最多读 64 字节),用例见 `test/web-terminal-resize.test.js`。降级成非 TTY 时 resize 自然无效。并发上限 `WEB_TERMINAL_MAX_SESSIONS = 20`,每条连接对应一个 `ptyProcess`,`ws` 关闭即 `SIGTERM`——所以前端任何「卸载终端组件」的改动都等于杀掉用户正在跑的 shell。
31
31
  - 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
32
32
  - 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
33
- - **环境说明**(`lib/agent-instructions.js` 唯一数据源,`buildWebAgentExecCommand` 的 `options.instructions`):每次执行时现读配置(`runs.<快照里的 run 名>.agentInstructions` > 全局),claude 用 `--append-system-prompt`、codex 用 `-c developer_instructions=<TOML 字符串>`、opencode 把说明写进容器 `/tmp/manyoyo-agent-instructions.md` 再经 `OPENCODE_CONFIG_CONTENT` 合并(1.19.3 实测与用户的 instructions 拼接);gemini 只有 `GEMINI_SYSTEM_MD` 这种整体替换,不注入。用户模板里已有同名参数 / 变量就不注入;用户追加的文本去控制字符、把 `{prompt}` 拆开(否则会被模板替换成提示词)。CLI 的 `manyoyo run` 是交互终端,不注入。
33
+ - **系统提示词**(`lib/agent-instructions.js` 唯一数据源,`buildWebAgentExecCommand` 的 `options.instructions`):全局配置(`agentInstructions` + 两个 `agentInstructions{Builtin,Custom}Enabled` 开关,`composeSystemPrompt`)只决定**新建容器时的初始内容**;创建时(请求 `systemPrompt:{enabled,text}`,没给就现算)存进容器状态目录 `system-prompt.json`,之后只能经 `/api/containers/:name/system-prompt` 改,改系统设置不影响已有容器。每次执行时读该容器的文件(`readContainerSystemPrompt`),旧容器没有文件就退回按当前全局配置现算;claude 用 `--append-system-prompt`、codex 用 `-c developer_instructions=<TOML 字符串>`、opencode 把说明写进容器 `/tmp/manyoyo-agent-instructions.md` 再经 `OPENCODE_CONFIG_CONTENT` 合并(1.19.3 实测与用户的 instructions 拼接);gemini 只有 `GEMINI_SYSTEM_MD` 这种整体替换,不注入。用户模板里已有同名参数 / 变量就不注入;用户追加的文本去控制字符、把 `{prompt}` 拆开(否则会被模板替换成提示词)。CLI 的 `manyoyo run` 是交互终端,不注入。
34
34
  - **会话标题**:claude 交互模式写 `ai-title`、`/rename` 写 `custom-title`(优先),**`-p` 下实测不生成 `ai-title`**(2.1.290,3 轮)。每轮结束后 `refreshWebAgentTitle` 在后台只读会话 jsonl 尾部 256KB(session id 按 UUID 校验),失败静默不写日志;`firstPrompt` 在第一次追加 Agent 用户消息时记下(历史会被裁剪,不能现算)。这些字段与 `pinned`、`slashCommands` 都要同时加进 `createEmptyWebAgentSession` 和 `normalizeWebAgentSessionRecord`,否则落盘丢失。
35
35
  - **斜杠命令候选**(`GET .../slash-commands`):来自 claude 每次运行 init 事件的 `slash_commands` / `skills` / `terminal_slash_commands`,只列 skill 和 `HEADLESS_BUILTIN_COMMANDS`(`-p` 下逐个实测过;`compact` 在 `-p` 下不压缩,别加回去)。
36
36
  - **改动视图**(`lib/web/git-changes.js`,`GET .../git/changes`、`.../git/diff?path=`):按需执行、不轮询;仓库和 git 配置由容器控制,命令里关掉外部 diff / textconv / fsmonitor、`GIT_OPTIONAL_LOCKS=0`、输出有上限;diff 的 path 必须出现在服务端重新取的改动列表里,`-` 开头和换行 / NUL 一律拒绝。
@@ -10,6 +10,7 @@ const { envUrlDomains } = require('../network-endpoints');
10
10
  const { egressPaths } = require('../egress-sidecar');
11
11
  const deniedLib = require('../egress-denied');
12
12
  const { isBrowserBackground } = require('../browser-background');
13
+ const { composeSystemPrompt, SYSTEM_PROMPT_MAX_CHARS } = require('../agent-instructions');
13
14
 
14
15
  // 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
15
16
  // 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
@@ -124,6 +125,13 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
124
125
  }
125
126
  }
126
127
 
128
+ function currentSystemPrompt(id) {
129
+ const saved = state_.readSystemPrompt(homeDir, id);
130
+ if (saved) return saved;
131
+ const snapshot = deps.readConfig ? deps.readConfig() : {};
132
+ return { enabled: true, text: composeSystemPrompt(snapshot, '') };
133
+ }
134
+
127
135
  function readPolicy(id) {
128
136
  return normalizePolicy(state_.readNetworkRaw(homeDir, id) || undefined);
129
137
  }
@@ -453,6 +461,31 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
453
461
  });
454
462
  }
455
463
  },
464
+ {
465
+ // 容器自己的系统提示词:创建时定下,之后只在这里改;旧容器没存过时显示按系统设置现算的内容
466
+ method: 'GET',
467
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/system-prompt$/),
468
+ handler: async match => {
469
+ const target = resolveTarget(match[1]);
470
+ if (!target) return;
471
+ sendJson(res, 200, { legacy: false, id: target.id, ...currentSystemPrompt(target.id) });
472
+ }
473
+ },
474
+ {
475
+ method: 'PUT',
476
+ match: p => p.match(/^\/api\/containers\/([^/]+)\/system-prompt$/),
477
+ handler: async match => {
478
+ const target = resolveTarget(match[1], { write: true });
479
+ if (!target) return;
480
+ const body = await readJsonBody(req);
481
+ if (typeof body.text !== 'string' || typeof body.enabled !== 'boolean') {
482
+ sendJson(res, 400, { error: '需要 enabled(布尔)和 text(字符串)' });
483
+ return;
484
+ }
485
+ state_.writeSystemPrompt(homeDir, target.id, { enabled: body.enabled, text: body.text.slice(0, SYSTEM_PROMPT_MAX_CHARS) });
486
+ sendJson(res, 200, { legacy: false, id: target.id, ...currentSystemPrompt(target.id) });
487
+ }
488
+ },
456
489
  {
457
490
  method: 'POST',
458
491
  match: p => p.match(/^\/api\/containers\/([^/]+)\/autostart\/run$/),