@xcanwin/manyoyo 8.6.10 → 8.6.12
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/README.md +2 -2
- package/bin/manyoyo.js +12 -4
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/app-update.js +15 -5
- package/lib/code-update.js +54 -7
- package/lib/container-network.js +48 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/setup-cli.js +18 -4
- package/lib/setup.js +7 -1
- package/lib/web/AGENTS.md +3 -0
- package/lib/web/agent-queue.js +5 -1
- package/lib/web/auth-store.js +167 -0
- package/lib/web/container-manage.js +2 -2
- package/lib/web/denied-notify.js +5 -0
- package/lib/web/index.html +86 -86
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +794 -68
- package/lib/web/snapshots.js +245 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +6 -6
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 模型服务商预设:网页向导与命令行向导共用的唯一数据源。
|
|
4
|
+
// 只收录实测可用(Base URL 不带 Key 请求得到 401)且官方文档给了接入方式的服务商。
|
|
5
|
+
// claude:Anthropic 兼容接口;codex:Responses 接口(只收官方文档确认支持 Responses 的)。
|
|
6
|
+
// models 只作“模型”输入框的占位提示,不会自动写入配置;defaultModel 是官方文档要求必须填的,用户可改。
|
|
7
|
+
// Codex 不认环境变量 OPENAI_BASE_URL(0.160 / 0.164 实测),向导写的仍是环境变量,
|
|
8
|
+
// 由 lib/agent-adapters 的 withCodexBaseUrl 在容器里展开成 `-c openai_base_url=…` 才生效。
|
|
9
|
+
// 智谱的接口把鉴权失败写在 200 响应体里(`{"code":401,...}`),其余三家直接返回 HTTP 401。
|
|
10
|
+
const PROVIDER_PRESETS = [
|
|
11
|
+
{
|
|
12
|
+
id: 'deepseek',
|
|
13
|
+
label: 'DeepSeek',
|
|
14
|
+
docUrl: 'https://api-docs.deepseek.com/guides/coding_agents',
|
|
15
|
+
claude: { baseUrl: 'https://api.deepseek.com/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['deepseek-flash[1m]'], defaultModel: 'deepseek-flash[1m]' }
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'zhipu',
|
|
19
|
+
label: '智谱 GLM',
|
|
20
|
+
docUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/claude',
|
|
21
|
+
claude: { baseUrl: 'https://open.bigmodel.cn/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
|
|
22
|
+
codex: { baseUrl: 'https://open.bigmodel.cn/api/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/codex' }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'zai',
|
|
26
|
+
label: 'Z.ai(智谱国际)',
|
|
27
|
+
docUrl: 'https://docs.z.ai/devpack/tool/claude',
|
|
28
|
+
claude: { baseUrl: 'https://api.z.ai/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' }
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'kimi',
|
|
32
|
+
label: 'Kimi',
|
|
33
|
+
docUrl: 'https://platform.kimi.com/docs/guide/claude-code-kimi.md',
|
|
34
|
+
claude: { baseUrl: 'https://api.moonshot.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
|
|
35
|
+
codex: { baseUrl: 'https://api.moonshot.cn/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://platform.kimi.com/docs/guide/codex-kimi.md' }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'kimi-code',
|
|
39
|
+
label: 'Kimi Code(订阅,海外)',
|
|
40
|
+
docUrl: 'https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html',
|
|
41
|
+
claude: { baseUrl: 'https://api.kimi.ai/coding/', keyEnv: 'ANTHROPIC_API_KEY' }
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'aliyun',
|
|
45
|
+
label: '阿里云百炼',
|
|
46
|
+
docUrl: 'https://help.aliyun.com/zh/model-studio/claude-code',
|
|
47
|
+
claude: { baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'tencent',
|
|
51
|
+
label: '腾讯云',
|
|
52
|
+
docUrl: 'https://cloud.tencent.com/document/product/1823/130097',
|
|
53
|
+
claude: { baseUrl: 'https://api.lkeap.cloud.tencent.com/coding/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['glm-5'] }
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'volcengine',
|
|
57
|
+
label: '火山方舟',
|
|
58
|
+
docUrl: 'https://www.volcengine.com/docs/82379/1928262',
|
|
59
|
+
claude: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
|
|
60
|
+
codex: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', keyEnv: 'OPENAI_API_KEY' }
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'minimax',
|
|
64
|
+
label: 'MiniMax',
|
|
65
|
+
docUrl: 'https://platform.minimax.cn/docs/token-plan/claude-code',
|
|
66
|
+
claude: { baseUrl: 'https://api.minimax.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
|
|
67
|
+
codex: { baseUrl: 'https://api.minimax.cn/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://platform.minimax.cn/docs/token-plan/codex' }
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** 某个 Agent 可用的预设(扁平:{id, label, docUrl, baseUrl, keyEnv, models, defaultModel}) */
|
|
72
|
+
function providersForAgent(agentId) {
|
|
73
|
+
return PROVIDER_PRESETS
|
|
74
|
+
.filter(item => item[agentId])
|
|
75
|
+
.map(item => ({
|
|
76
|
+
id: item.id,
|
|
77
|
+
label: item.label,
|
|
78
|
+
docUrl: item[agentId].docUrl || item.docUrl,
|
|
79
|
+
baseUrl: item[agentId].baseUrl,
|
|
80
|
+
keyEnv: item[agentId].keyEnv,
|
|
81
|
+
models: [...(item[agentId].models || [])],
|
|
82
|
+
defaultModel: item[agentId].defaultModel || ''
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { PROVIDER_PRESETS, providersForAgent };
|
package/lib/setup-cli.js
CHANGED
|
@@ -154,11 +154,23 @@ async function runSetupCli(deps) {
|
|
|
154
154
|
}));
|
|
155
155
|
keyName = agent.requiredAnyOf[keyIndex];
|
|
156
156
|
}
|
|
157
|
+
let defaultModel = '';
|
|
157
158
|
if (modeIndex === 1) {
|
|
158
|
-
const
|
|
159
|
+
const providers = agent.providers || [];
|
|
160
|
+
const baseUrlItems = [
|
|
161
|
+
...providers.map(item => `${item.label} ${item.baseUrl}`),
|
|
162
|
+
...agent.baseUrlPresets.map(item => `${item.label} ${item.value}`),
|
|
163
|
+
'自定义地址'
|
|
164
|
+
];
|
|
159
165
|
const baseIndex = await chooseNumber(prompter, log, 'Base URL:', baseUrlItems);
|
|
160
|
-
if (baseIndex <
|
|
161
|
-
|
|
166
|
+
if (baseIndex < providers.length) {
|
|
167
|
+
const provider = providers[baseIndex];
|
|
168
|
+
env[agent.baseUrlKey] = provider.baseUrl;
|
|
169
|
+
keyName = provider.keyEnv;
|
|
170
|
+
defaultModel = provider.defaultModel;
|
|
171
|
+
log(`官方说明:${provider.docUrl}`);
|
|
172
|
+
} else if (baseIndex < providers.length + agent.baseUrlPresets.length) {
|
|
173
|
+
env[agent.baseUrlKey] = agent.baseUrlPresets[baseIndex - providers.length].value;
|
|
162
174
|
} else {
|
|
163
175
|
const custom = String(await prompter.ask('请输入 Base URL: ')).trim();
|
|
164
176
|
if (!custom) {
|
|
@@ -174,7 +186,9 @@ async function runSetupCli(deps) {
|
|
|
174
186
|
return 1;
|
|
175
187
|
}
|
|
176
188
|
env[keyName] = key;
|
|
177
|
-
const model = String(await prompter.ask(
|
|
189
|
+
const model = String(await prompter.ask(defaultModel
|
|
190
|
+
? `模型(${agent.modelKey},回车使用 ${defaultModel}): `
|
|
191
|
+
: `模型(${agent.modelKey},可选,回车跳过): `)).trim() || defaultModel;
|
|
178
192
|
if (model) env[agent.modelKey] = model;
|
|
179
193
|
|
|
180
194
|
log('\n第 3 / 4 步 · 工作目录');
|
package/lib/setup.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { AGENT_ENV_SCHEMA } = require('./init-config');
|
|
4
4
|
const { parseEnvEntry } = require('./runtime-normalizers');
|
|
5
|
+
const { providersForAgent } = require('./provider-presets');
|
|
5
6
|
|
|
6
7
|
const SETUP_AGENTS = Object.keys(AGENT_ENV_SCHEMA);
|
|
7
8
|
|
|
@@ -20,6 +21,7 @@ function listSetupAgents() {
|
|
|
20
21
|
requiredAnyOf: [...spec.requiredAnyOf],
|
|
21
22
|
baseUrlKey: spec.baseUrlKey,
|
|
22
23
|
baseUrlPresets: spec.baseUrlPresets.map(item => ({ ...item })),
|
|
24
|
+
providers: providersForAgent(id),
|
|
23
25
|
env: spec.env.map(item => ({ ...item }))
|
|
24
26
|
};
|
|
25
27
|
});
|
|
@@ -92,7 +94,7 @@ const CONNECTION_RULES = [
|
|
|
92
94
|
];
|
|
93
95
|
|
|
94
96
|
/**
|
|
95
|
-
* 把容器内最小请求的结果归类为 success / network / auth / other。
|
|
97
|
+
* 把容器内最小请求的结果归类为 success / network / auth / missing_cli / other。
|
|
96
98
|
* 返回值只含固定文案和经过脱敏的尾部片段,不回传完整输出。
|
|
97
99
|
*/
|
|
98
100
|
function classifyConnectionResult(result, secrets = []) {
|
|
@@ -105,6 +107,10 @@ function classifyConnectionResult(result, secrets = []) {
|
|
|
105
107
|
if (exitCode === 124) {
|
|
106
108
|
return { category: 'network', message: '请求超时:60 秒内没有得到响应,请检查网络、代理或 Base URL', detail };
|
|
107
109
|
}
|
|
110
|
+
const missing = output.match(/([A-Za-z0-9_.-]+): (?:command )?not found/i);
|
|
111
|
+
if (missing) {
|
|
112
|
+
return { category: 'missing_cli', message: `镜像里没有 ${missing[1]}`, detail };
|
|
113
|
+
}
|
|
108
114
|
for (const rule of CONNECTION_RULES) {
|
|
109
115
|
if (rule.pattern.test(output)) {
|
|
110
116
|
return { category: rule.category, message: rule.message, detail };
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
- `content_delta`:每条 assistant 消息落地时下发一次的权威全文,前端**整体覆盖**。
|
|
15
15
|
- `queue_next`(新增):这一轮结束后服务端接着发排队的下一条,前端收到后结束这条流、改走恢复轮询(见下面“追加提示词”)。
|
|
16
16
|
- `trace`:**不保证带结构化 `traceEvent`**。stderr 行(`emitStderrTraceLine`)和解析不出 JSON 的 stdout 行只有 `text` + `stream`,前端靠 `toTraceEvent()` 兜底成 `kind="output"` 的事件展示。新增只认 `traceEvent` 的分支等于把自定义 CLI、CLI 报错的输出全吞掉。
|
|
17
|
+
- `network_denied`:`{host, model}`,过滤代理拦下了一个域名。`model:true` 表示是本轮 Agent 自己的模型地址(`modelDomains`:env 里的 URL 域名 + 没配 BASE_URL 时 `AGENT_ENV_SCHEMA` 的官方域名),服务端随后用 `terminateWebAgentRun` 中止本轮(**不能走 `stopWebAgentRun`**:它会把排队暂停落盘;本轮结束后也不自动发队列),最终回复带 `networkDenied:{host,model}` 并落盘,`result` 事件同带该字段;前端据历史里最后一条回复推出“允许并重试”提示条,所以刷新页面也在。非模型域名的拦截仍只是一条 `trace`。
|
|
17
18
|
- `ping`:保活心跳,前端忽略。`DEFAULT_AGENT_STREAM_HEARTBEAT_MS` 必须明显小于反向代理的空闲读超时(nginx `proxy_read_timeout` 默认 60s),否则 agent 静默期间流会被 RST,浏览器侧表现为输入框上方冒出 `network error`。
|
|
18
19
|
- token 级增量**不写**事件日志、历史落盘按 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 节流;新增逐事件的落盘/日志动作前先想清楚它会不会被每个 token 触发一次。
|
|
19
20
|
- **追加提示词(排队)**:任务进行中用户可以追加提示词,队列存在 `agentSession.queue`(`lib/web/agent-queue.js`,最多 10 条,`when` 为 `now` / `step` / `turn`;`normalizeWebAgentSession` 的字段白名单里要有它,否则落盘后丢失)。接口 `POST /api/sessions/:name/agent/queue`(clientMessageId 幂等;容器被别的会话占着返回 409)、`DELETE .../agent/queue/:id`、`POST .../agent/queue/continue`;`/messages` 带 `queue`。一轮对话的完整流程抽成 `runWebAgentStreamTurn(..., res)`:前端不在线时传 `createHeadlessResponse()` 顶替 `res`,事件照常落盘。每一轮结束(完成 / 失败 / 被追加打断,**用户点停止除外**,由 `stopWebAgentRun` 置 `runState.userStopped`,并把 `agentSession.queuePaused` **落盘**——只记在内存里的话,serve 重启或用户下一次正常发送、那一轮结束后,停止前的排队会被自动发出去;暂停只有 `POST .../agent/queue/continue` 能解除,队列清空时自动解除,`/messages` 带 `queuePaused`)后若队列可以自动发(`agentQueue.shouldAutoDispatch`),先给连着的流发一个 `queue_next` 事件(前端据此结束这条流、改走恢复轮询),再 `setImmediate` 调 `dispatchNextQueued`(同步取出队首并占锁,开不起来就放回队首)。`now` 立即走进程树信号;`step` 在 `isStepBoundary`(一次工具 / 子 Agent / 说明完成)处打断,`agentStepFallbackMs`(默认 120 秒)等不到就退化成 `now`。serve 重启后 `startContainerServices` 末尾调 `resumeQueuedAgentTurns`,把留在队列里(暂停的除外)又没有任务在跑的内容当作 `turn` 发出;发之前用 `buildLiveRunsCommand`(只读,校验规则同停止脚本)看容器里还有没有活着的 Agent 进程,有就不发、排队留给用户——那可能是上一个 serve 的孤儿任务,也可能是同一 HOME 下另一个 serve 实例正在跑的任务,所以只检测不杀。
|
|
20
21
|
- **停止 / 打断必须进到容器里发信号**(`lib/web/agent-run-control.js`):只杀宿主机上的 `docker exec` 客户端,容器里的 Agent 进程会继续跑(实测)。命令被包成 `echo $$ > /tmp/manyoyo-run-<id>.pid; <命令>; exit $?`,**末尾的 `exit $?` 不能省**——命令是最后一条简单命令时 bash 会 exec 替换自己,`/proc/<pid>/cmdline` 里的 `manyoyo-run-<id>.pid` 标记就没了,停止时会因“认不出进程”而不杀(真实容器测出来的,单测里用 `sleep 61; true` 测不到)。PID 文件容器里可写,按不可信输入处理(纯数字、普通文件、命令行带标记才发信号),再按子孙进程树逐级 INT → TERM → KILL。真实的 claude 收到 SIGINT 会正常收尾、**退出码为 0**,所以 `interrupted` 看“发过信号”(`runState.signaled`)而不只是退出码。已验证 Claude 打断后 `-r <id>` 能接上上下文;Codex / Gemini / OpenCode 没有环境验证,拿不到引擎会话 id 时走既有的历史注入降级。
|
|
22
|
+
- **登录会话落盘**(`lib/web/auth-store.js`):内存为准,落盘到 `~/.manyoyo/serve/auth-sessions-<host>_<port>.json`(按监听地址分开;`bin/manyoyo.js` 传 `authSessionsFile`,测试不传就只在内存里,别让测试写真实目录)。只存 `sha256(token)` 和凭据指纹 `HMAC(盐, 用户名\0密码)`:用户名 / 密码变了(含每次启动随机生成的密码)整份作废并**马上覆盖**(不覆盖的话改回旧密码旧会话会复活)。登录 / 登出立刻写,滑动续期只在到期时间比已落盘的晚 1 小时以上才合并延迟写;文件损坏、权限不是 0600 当空的并 warn。会话到期时间用真实 `Date.now`,不要用测试可注入的 `ctx.nowMs`(限速用例会拨时钟)。
|
|
23
|
+
- **启动对账**(`reconcileOrphanTurns`,在 `startWebServer` 里直接挂,**不要**挂在 `startContainerNetworkWatch` 上:测试替身没有 `watchStarts`):启动时记下当时 `pending===true` 的消息 id,只处理这批。容器里没有活着的运行(`listLiveRunsInContainer`;容器不存在 / 没运行 / 查询失败都算没有)→ 立即标 `interrupted`,执行过程补“[任务] 服务重启,本轮已中断”,流式半截回复末尾(没有就新建一条)写说明,并记 `process.interrupted` 控制事件;还有活着的(docker 下 agent 比 serve 活得久)→ 每 5 秒(`orphanPollMs`)查一次,跑完再按“后台跑完”文案处理。该会话已有新的一轮在跑时不在末尾插入说明。`resumeQueuedAgentTurns` 先等这次对账,否则会在还挂着旧 pending 的会话上再开新一轮。
|
|
21
24
|
- **子 Agent**:Claude 的 `Agent`(旧名 `Task`)工具生成 `kind: "subagent"` 事件,子 Agent 里的事件带 `parentToolId`(取自 `parent_tool_use_id`);带 `parent_tool_use_id` 的 assistant 文本不当作主 Agent 的回复。`--forward-subagent-text` 只在 `claude --help` 里有该参数时才加(命令里就地探测,约 50ms,不缓存)。样本 fixture:`test/fixtures/claude-subagent.ndjson`。
|
|
22
25
|
- **最终回复的取值**(`resolveAgentFinalOutput`):原始 stdout 只保留开头 `AGENT_RAW_OUTPUT_MAX_CHARS`(32MB,会话 id 在开头),另外用 `createTailBuffer` 保留结尾(8MB)。最终回复和用量都在结尾:超过上限后只解析开头,会把截断点之前的某条中间说明当成最终回复(每个 token 一行约 260 字节的增量,加上思考 token、子 Agent、大段工具结果,长任务很容易超过);所以截断时先从结尾提取,提取不到再看开头,用量也优先看结尾(`stdoutTail`)。Claude 以最后一个 `result` 事件的 `result` 为准(后台任务场景结尾有两个 `result`,第一个是中间结论)。提取到的最终回复**不截断**(与流式落盘一致),提取不到才退回原始输出并按 `WEB_OUTPUT_MAX_CHARS` 截断。子进程的 stdout / stderr 必须 `setEncoding('utf8')` 按流解码:逐块 `toString` 会把跨块的中文拆成乱码。测试可用 `agentRawOutputMaxChars` 调小上限;真实样本 `test/fixtures/claude-background-task.ndjson`、`claude-subagent-parallel.ndjson`(claude 2.1.290,已精简)。
|
|
23
26
|
- **历史读写在 `lib/web/history-store.js`**:进程内缓存 + 原子写(`writeConfigFileSecure`,0600)+ 合并写盘。读靠 `stat` 的 `ino + mtimeMs + size` 判断缓存是否还有效(别的进程原子替换也能识别);`loadWebSessionHistory` 返回的是**缓存里的同一个对象**,改了必须 `saveWebSessionHistory`,只读路由不要改它(`getWebAgentSession(..., { create: true })` 会在缓存里留一个未保存的空 agent,只在马上要写的路径里用)。流式过程中的 trace patch 和控制事件走 `{ defer: true }`(只改内存,最迟 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 后落盘);`process.exit` / serve 关闭会 flush;`result` / `error` 类事件(`WEB_STREAM_FLUSH_EVENT_TYPES`)和所有非 defer 写入立即写盘并带上攒着的内容。`kill -9` 最多丢约 1 秒的 trace。文件解析失败会留一份 `.corrupt-<时间>` 备份再按空历史处理,不要绕过它。磁盘格式只有 `agents.<id>.messages`(不再有顶层 `messages`,紧凑 JSON),老版本(含 `update --rollback`)读取时优先用 `agents`,能读新文件。
|
package/lib/web/agent-queue.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// 只放纯函数,调用方负责 saveWebSessionHistory。
|
|
5
5
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
|
+
const { IMAGE_PATH_PATTERN } = require('./uploads');
|
|
7
8
|
|
|
8
9
|
const QUEUE_MAX = 10;
|
|
9
10
|
const QUEUE_WHEN = ['now', 'step', 'turn'];
|
|
@@ -31,13 +32,14 @@ function publicQueue(agentSession) {
|
|
|
31
32
|
id: item.id,
|
|
32
33
|
clientMessageId: item.clientMessageId,
|
|
33
34
|
text: item.text,
|
|
35
|
+
...(Array.isArray(item.images) && item.images.length ? { images: item.images } : {}),
|
|
34
36
|
when: item.when,
|
|
35
37
|
createdAt: item.createdAt
|
|
36
38
|
}));
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
// now / step 排在所有 turn 之前(它们之间保持先来先到);turn 排到队尾
|
|
40
|
-
function enqueue(agentSession, { clientMessageId, text, when }) {
|
|
42
|
+
function enqueue(agentSession, { clientMessageId, text, when, images }) {
|
|
41
43
|
const queue = getQueue(agentSession);
|
|
42
44
|
if (clientMessageId) {
|
|
43
45
|
const existing = queue.find(item => item.clientMessageId === clientMessageId);
|
|
@@ -52,6 +54,7 @@ function enqueue(agentSession, { clientMessageId, text, when }) {
|
|
|
52
54
|
id: `q-${crypto.randomBytes(6).toString('hex')}`,
|
|
53
55
|
clientMessageId: clientMessageId || '',
|
|
54
56
|
text,
|
|
57
|
+
...(Array.isArray(images) && images.length ? { images } : {}),
|
|
55
58
|
when,
|
|
56
59
|
createdAt: new Date().toISOString()
|
|
57
60
|
};
|
|
@@ -126,6 +129,7 @@ function normalizeStoredQueue(value) {
|
|
|
126
129
|
id: item.id,
|
|
127
130
|
clientMessageId: typeof item.clientMessageId === 'string' ? item.clientMessageId : '',
|
|
128
131
|
text: item.text,
|
|
132
|
+
...(Array.isArray(item.images) ? { images: item.images.filter(path => typeof path === 'string' && IMAGE_PATH_PATTERN.test(path)).slice(0, 5) } : {}),
|
|
129
133
|
when: item.when,
|
|
130
134
|
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString()
|
|
131
135
|
}));
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { writeConfigFileSecure } = require('../secure-file');
|
|
7
|
+
|
|
8
|
+
// 网页登录会话:内存为准,落盘只为 serve 重启后不把人踢下线。
|
|
9
|
+
// 文件只存 sha256(token)(不存 token 明文)和一个“凭据指纹”HMAC(随机盐, 用户名\0密码):
|
|
10
|
+
// 改了用户名或密码(含每次启动随机生成的密码变了)指纹就对不上,整份作废。
|
|
11
|
+
const FILE_VERSION = 1;
|
|
12
|
+
const DEFAULT_FLUSH_DELAY_MS = 1000;
|
|
13
|
+
// 滑动续期不是每个请求都写盘:新的到期时间比已落盘的晚到这个值才写
|
|
14
|
+
const REWRITE_SLACK_MS = 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function hashToken(token) {
|
|
17
|
+
return crypto.createHash('sha256').update(String(token)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fingerprintOf(salt, user, pass) {
|
|
21
|
+
return crypto.createHmac('sha256', Buffer.from(salt, 'hex')).update(`${user}\0${pass}`).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function authSessionsFilePath(homeDir, host, port) {
|
|
25
|
+
const safeHost = String(host || 'host').replace(/[^A-Za-z0-9.-]/g, '_');
|
|
26
|
+
return path.join(homeDir, '.manyoyo', 'serve', `auth-sessions-${safeHost}_${Number(port) || 0}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeEqualHex(a, b) {
|
|
30
|
+
const x = Buffer.from(String(a), 'hex');
|
|
31
|
+
const y = Buffer.from(String(b), 'hex');
|
|
32
|
+
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {string} [options.file] 落盘路径;不给就只在内存里
|
|
38
|
+
* @param {string} options.user
|
|
39
|
+
* @param {string} options.pass
|
|
40
|
+
* @param {number} options.ttlMs
|
|
41
|
+
* @param {() => number} [options.now]
|
|
42
|
+
* @param {(message: string) => void} [options.warn]
|
|
43
|
+
*/
|
|
44
|
+
function createAuthStore(options) {
|
|
45
|
+
const now = options.now || Date.now;
|
|
46
|
+
const warn = options.warn || (() => {});
|
|
47
|
+
const file = options.file || '';
|
|
48
|
+
const ttlMs = options.ttlMs;
|
|
49
|
+
const flushDelayMs = options.flushDelayMs || DEFAULT_FLUSH_DELAY_MS;
|
|
50
|
+
const sessions = new Map(); // sha256(token) → { username, expiresAt }
|
|
51
|
+
const persisted = new Map(); // sha256(token) → 已落盘的 expiresAt
|
|
52
|
+
let salt = crypto.randomBytes(16).toString('hex');
|
|
53
|
+
let timer = null;
|
|
54
|
+
let dirty = false;
|
|
55
|
+
let warnedWrite = false;
|
|
56
|
+
|
|
57
|
+
function serialize() {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [key, session] of sessions) out[key] = { username: session.username, expiresAt: session.expiresAt };
|
|
60
|
+
return `${JSON.stringify({ version: FILE_VERSION, salt, fingerprint: fingerprintOf(salt, options.user, options.pass), sessions: out })}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function flush() {
|
|
64
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
65
|
+
dirty = false;
|
|
66
|
+
if (!file) return;
|
|
67
|
+
try {
|
|
68
|
+
writeConfigFileSecure(file, serialize());
|
|
69
|
+
persisted.clear();
|
|
70
|
+
for (const [key, session] of sessions) persisted.set(key, session.expiresAt);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (!warnedWrite) {
|
|
73
|
+
warnedWrite = true;
|
|
74
|
+
warn(`登录会话写盘失败(重启后需要重新登录): ${e && e.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scheduleFlush() {
|
|
80
|
+
if (!file) return;
|
|
81
|
+
dirty = true;
|
|
82
|
+
if (timer) return;
|
|
83
|
+
timer = setTimeout(flush, flushDelayMs);
|
|
84
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function load() {
|
|
88
|
+
if (!file) return;
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
const stat = fs.lstatSync(file);
|
|
92
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0) {
|
|
93
|
+
warn(`登录会话文件不是 0600 的普通文件,已忽略: ${file}`);
|
|
94
|
+
flush();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e && e.code === 'ENOENT') return;
|
|
100
|
+
warn(`登录会话文件读取失败,已忽略: ${e && e.message}`);
|
|
101
|
+
flush();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const valid = raw && raw.version === FILE_VERSION && typeof raw.salt === 'string' && /^[0-9a-f]{32}$/.test(raw.salt)
|
|
105
|
+
&& typeof raw.fingerprint === 'string' && raw.sessions && typeof raw.sessions === 'object';
|
|
106
|
+
if (!valid || !safeEqualHex(raw.fingerprint, fingerprintOf(raw.salt, options.user, options.pass))) {
|
|
107
|
+
// 改过用户名或密码:整份作废,并马上覆盖(否则改回旧密码后旧会话会复活)
|
|
108
|
+
flush();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
salt = raw.salt;
|
|
112
|
+
const current = now();
|
|
113
|
+
for (const [key, session] of Object.entries(raw.sessions)) {
|
|
114
|
+
if (/^[0-9a-f]{64}$/.test(key) && session && typeof session.username === 'string' && Number.isFinite(session.expiresAt) && session.expiresAt > current) {
|
|
115
|
+
sessions.set(key, { username: session.username, expiresAt: session.expiresAt });
|
|
116
|
+
persisted.set(key, session.expiresAt);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function prune() {
|
|
122
|
+
const current = now();
|
|
123
|
+
let removed = false;
|
|
124
|
+
for (const [key, session] of sessions) {
|
|
125
|
+
if (session.expiresAt <= current) {
|
|
126
|
+
sessions.delete(key);
|
|
127
|
+
removed = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (removed) scheduleFlush();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
load();
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
create(username) {
|
|
137
|
+
prune();
|
|
138
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
139
|
+
sessions.set(hashToken(token), { username, expiresAt: now() + ttlMs });
|
|
140
|
+
flush();
|
|
141
|
+
return token;
|
|
142
|
+
},
|
|
143
|
+
// 命中则顺延到期时间(滑动过期)
|
|
144
|
+
touch(token) {
|
|
145
|
+
if (!token) return null;
|
|
146
|
+
prune();
|
|
147
|
+
const key = hashToken(token);
|
|
148
|
+
const session = sessions.get(key);
|
|
149
|
+
if (!session) return null;
|
|
150
|
+
session.expiresAt = now() + ttlMs;
|
|
151
|
+
if (file && session.expiresAt - (persisted.get(key) || 0) >= REWRITE_SLACK_MS) scheduleFlush();
|
|
152
|
+
return { username: session.username };
|
|
153
|
+
},
|
|
154
|
+
remove(token) {
|
|
155
|
+
if (!token) return;
|
|
156
|
+
if (sessions.delete(hashToken(token))) flush();
|
|
157
|
+
},
|
|
158
|
+
close() {
|
|
159
|
+
if (dirty) flush();
|
|
160
|
+
else if (timer) { clearTimeout(timer); timer = null; }
|
|
161
|
+
},
|
|
162
|
+
// 仅测试用
|
|
163
|
+
size() { return sessions.size; }
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { createAuthStore, authSessionsFilePath };
|
|
@@ -6,7 +6,7 @@ const state_ = require('../container-state');
|
|
|
6
6
|
const { resolveContainerId } = require('../container-exec');
|
|
7
7
|
const { normalizePolicy, resolveContainerRefs, isUnrestricted, domainRuleAllows, targetKind } = require('../network-policy');
|
|
8
8
|
const { parseEnvEntry } = require('../runtime-normalizers');
|
|
9
|
-
const {
|
|
9
|
+
const { modelDomains } = require('../network-endpoints');
|
|
10
10
|
const { egressPaths } = require('../egress-sidecar');
|
|
11
11
|
const deniedLib = require('../egress-denied');
|
|
12
12
|
const { isBrowserBackground } = require('../browser-background');
|
|
@@ -206,7 +206,7 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
206
206
|
...state_.readEnv(homeDir, target.id).entries.map(entry => `${entry.key}=${entry.value}`),
|
|
207
207
|
...state_.envFileEntries(homeDir, target.id).map(entry => `${entry.key}=${entry.value}`)
|
|
208
208
|
];
|
|
209
|
-
const suggestedDomains =
|
|
209
|
+
const suggestedDomains = modelDomains(envLines).filter(domain => !domainRuleAllows(policy, domain));
|
|
210
210
|
return {
|
|
211
211
|
legacy: false,
|
|
212
212
|
id: target.id,
|
package/lib/web/denied-notify.js
CHANGED
|
@@ -9,6 +9,8 @@ const DEFAULT_INTERVAL_MS = 2000;
|
|
|
9
9
|
* @param {() => {host: string, port: number, count: number}[]} options.read 当前拦截记录
|
|
10
10
|
* @param {(host: string) => boolean} options.isIgnored 不提示的域名(已放行、浏览器后台请求)
|
|
11
11
|
* @param {(text: string) => void} options.emit
|
|
12
|
+
* @param {(host: string) => boolean} [options.isModelHost] 是否 Agent 自己的模型域名
|
|
13
|
+
* @param {(host: string) => void} [options.onModelDenied] 模型域名被拦时调用(每个域名一次)
|
|
12
14
|
*/
|
|
13
15
|
function createDeniedNotifier(options) {
|
|
14
16
|
const intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
|
|
@@ -28,6 +30,9 @@ function createDeniedNotifier(options) {
|
|
|
28
30
|
if (record.count > (baseline.get(key) || 0) && !notified.has(record.host) && !options.isIgnored(record.host)) {
|
|
29
31
|
notified.add(record.host);
|
|
30
32
|
options.emit(`[提示] 已拦截:${record.host}(不在白名单),可在「容器 → 网络」里一键允许`);
|
|
33
|
+
if (options.onModelDenied && options.isModelHost && options.isModelHost(record.host)) {
|
|
34
|
+
options.onModelDenied(record.host);
|
|
35
|
+
}
|
|
31
36
|
}
|
|
32
37
|
baseline.set(key, record.count);
|
|
33
38
|
});
|