Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6865638a |
@@ -6,6 +6,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
|
||||
export const DEFAULT_DEEPSEEK_NO_THINK_PORT = 18036;
|
||||
export const DEFAULT_DEEPSEEK_UPSTREAM = 'https://api.deepseek.com';
|
||||
export const DEFAULT_MOONSHOT_UPSTREAM = 'https://api.moonshot.cn';
|
||||
export const DEEPSEEK_COMPAT_CONTRACT_VERSION = 1;
|
||||
|
||||
const insecureDispatcher = new Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
@@ -256,6 +257,8 @@ export function createDeepseekNoThinkProxy({
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
deepseekThinking: 'disabled',
|
||||
upstream: normalizedUpstream,
|
||||
moonshotUpstream: normalizedMoonshotUpstream,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
decodedUpstreamResponseHeaders,
|
||||
deepseekDisableThinkingEnabled,
|
||||
flattenLocalJsonSchemaRefs,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
resolveDeepseekNoThinkProxyBaseUrl,
|
||||
resolveMoonshotCompatProxyBaseUrl,
|
||||
sanitizeMoonshotToolSchemas,
|
||||
startDeepseekNoThinkProxy,
|
||||
} from './deepseek-no-think-proxy.mjs';
|
||||
|
||||
test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () => {
|
||||
@@ -168,3 +170,22 @@ test('decodedUpstreamResponseHeaders drops stale compression metadata', () => {
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
});
|
||||
|
||||
test('compatibility proxy health identifies the enforced production contract', async (t) => {
|
||||
const server = await startDeepseekNoThinkProxy({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/health`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: true,
|
||||
contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
deepseekThinking: 'disabled',
|
||||
upstream: 'https://api.deepseek.com',
|
||||
moonshotUpstream: 'https://api.moonshot.cn',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ Portal 的首个生产动作必须是用户级灰度,不能直接整包替换
|
||||
| 灰度边缘隧道 | `105 127.0.0.1:19082` | 反向 SSH 到 103 的 18082;不复用已退役的 19081 链路 |
|
||||
| 候选 Portal | `127.0.0.1:18081` | 独立、无源码 runtime;使用被动运行时模式 |
|
||||
| 候选 goosed | `127.0.0.1:18015` | 独立容器并挂载候选 runtime,不重建稳定 9 个容器 |
|
||||
| DeepSeek 兼容代理 | `103 0.0.0.0:18036` | 仅供宿主机和候选容器使用;不接入 105 或公网入口 |
|
||||
|
||||
候选 Portal 通过 `MEMIND_PORTAL_H5_ROOT` 共享经审计的持久数据和数据库,但
|
||||
`MEMIND_RUNTIME_ROLE=candidate` 会禁止重复启动 MindSpace、Plaza、提醒、订阅续期和
|
||||
@@ -23,6 +24,18 @@ Agent 恢复扫描等单例后台任务。
|
||||
`Failed to add extension` 和宿主机绝对路径的 `MODULE_NOT_FOUND`,应先核对候选
|
||||
进程环境与 `/opt/portal` 容器挂载,禁止把宿主机文件复制进容器临时修补。
|
||||
|
||||
DeepSeek V4 的工具轮次还必须经过候选 runtime 中的
|
||||
`deepseek-no-think-proxy.mjs`。候选启动脚本必须强制
|
||||
`MEMIND_DEEPSEEK_DISABLE_THINKING=1`,并把 goosed 自定义 provider 指向
|
||||
`http://host.docker.internal:18036/v1`。该代理通过独立 LaunchAgent 管理;
|
||||
发布必须同时验证 `/health` 的兼容契约和候选容器到该地址的连通性。灰度路由器把
|
||||
该健康地址纳入候选健康闭包,代理掉线时必须自动回落稳定 Portal。
|
||||
|
||||
若日志出现
|
||||
`The reasoning_content in the thinking mode must be passed back to the API.`,
|
||||
说明 DeepSeek 请求绕过了兼容契约。禁止只在线补环境变量:必须检查 artifact、
|
||||
LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于同一 release。
|
||||
|
||||
## 身份和回落规则
|
||||
|
||||
- `john`:按数据库中的唯一用户名或用户 ID 匹配。
|
||||
@@ -32,6 +45,7 @@ Agent 恢复扫描等单例后台任务。
|
||||
- 微信回调通过 `FromUserName` 和 `h5_user_wechat_identities` 只读解析到绑定账号,再用唯一账号标识匹配。
|
||||
- 未命中、无身份、过期/撤销会话、数据库异常:走稳定版本。
|
||||
- 候选 `/api/status` 不健康:自动走稳定版本。
|
||||
- DeepSeek 兼容代理不健康:自动走稳定版本。
|
||||
- SSE、附件、公开页和普通 API 均由同一反向代理转发;微信 XML body 必须原样保留。
|
||||
|
||||
## 发布入口
|
||||
@@ -49,12 +63,13 @@ bash scripts/release-portal-canary-prod.sh --yes
|
||||
2. 执行 103 只读预检。
|
||||
3. 上传经过验证的候选包。
|
||||
4. 创建稳定 live 全量备份、持久目录备份和 105 两份活动 nginx 配置备份,并校验完整性。
|
||||
5. 启动独立候选 goosed、候选 Portal 和灰度路由器。
|
||||
6. 验证不可变身份配置、稳定/候选健康和路由器诊断。
|
||||
7. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。
|
||||
8. 短暂阻止新 Agent Run,排空活动任务。
|
||||
9. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。
|
||||
10. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。
|
||||
5. 启动独立候选 goosed 和 DeepSeek 兼容代理,验证兼容契约及容器连通性。
|
||||
6. 启动候选 Portal 和灰度路由器。
|
||||
7. 验证不可变身份配置、稳定/候选/依赖健康和路由器诊断。
|
||||
8. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。
|
||||
9. 短暂阻止新 Agent Run,排空活动任务。
|
||||
10. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。
|
||||
11. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。
|
||||
|
||||
任一步失败都会把 105 nginx 恢复到稳定 `58.38.22.103:8081`,停止灰度隧道和候选服务,
|
||||
并保留候选目录与备份证据。活动 nginx 配置变更只能由已提交的发布脚本执行,禁止在线手改。
|
||||
@@ -68,7 +83,7 @@ bash scripts/rollback-portal-canary-prod.sh --yes
|
||||
回滚只执行以下动作:
|
||||
|
||||
- 把 105 两份活动 nginx 上游恢复到稳定 Portal `58.38.22.103:8081` 并验证 reload。
|
||||
- 停止灰度隧道、灰度路由器、候选 Portal 和候选 goosed。
|
||||
- 停止灰度隧道、灰度路由器、候选 Portal、候选 goosed 和 DeepSeek 兼容代理。
|
||||
- 清除发布排水标记。
|
||||
- 验证稳定 Portal 和 105 HTTPS 入口健康。
|
||||
|
||||
|
||||
@@ -210,6 +210,37 @@ Provider 'custom_deepseek' is not configured
|
||||
|
||||
注意:DeepSeek API key / provider secret 是运行配置,不应该烘焙进镜像。
|
||||
|
||||
### 4.7 DeepSeek 工具轮次兼容必须与生产拓扑一致
|
||||
|
||||
DeepSeek V4 思考模式在工具调用后的下一轮要求客户端回传上一轮
|
||||
`reasoning_content`。当前 goosed 工具链不能保证完整回传,因此生产候选必须使用
|
||||
artifact 内的 `deepseek-no-think-proxy.mjs`,由独立 LaunchAgent 在 `18036`
|
||||
启动,并强制候选 Portal 设置:
|
||||
|
||||
```bash
|
||||
MEMIND_DEEPSEEK_DISABLE_THINKING=1
|
||||
MEMIND_DEEPSEEK_NO_THINK_PORT=18036
|
||||
MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
|
||||
```
|
||||
|
||||
发布不能只看 Portal `/api/status`。还必须验证:
|
||||
|
||||
- `http://127.0.0.1:18036/health` 返回兼容契约版本和
|
||||
`deepseekThinking=disabled`;
|
||||
- `goosed-prod-canary` 能访问
|
||||
`http://host.docker.internal:18036/health`;
|
||||
- 灰度路由器把该地址纳入候选健康闭包;
|
||||
- 失败清理和回滚会停止对应 LaunchAgent。
|
||||
|
||||
Gate 本地栈和生产候选必须使用同一组开关与进程依赖。若出现:
|
||||
|
||||
```text
|
||||
The reasoning_content in the thinking mode must be passed back to the API.
|
||||
```
|
||||
|
||||
说明实际生产请求绕过了兼容契约;即使场景 Gate 全部通过,也必须阻断发布并排查
|
||||
环境漂移。
|
||||
|
||||
## 5. goosed-prod 镜像更新必看
|
||||
|
||||
当前已验证镜像:
|
||||
|
||||
@@ -800,13 +800,13 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a
|
||||
);
|
||||
});
|
||||
|
||||
test('syncProfileToGoosed routes local DeepSeek through the no-thinking proxy', async () => {
|
||||
test('syncProfileToGoosed routes explicitly protected production DeepSeek through the compatibility proxy', async () => {
|
||||
const previousProfile = process.env.MEMIND_RUNTIME_PROFILE;
|
||||
const previousDisableThinking = process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
const previousProxyBase = process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
const previousGateway = process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'local';
|
||||
delete process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'production';
|
||||
process.env.MEMIND_DEEPSEEK_DISABLE_THINKING = '1';
|
||||
delete process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
process.env.MEMIND_GOOSED_HOST_GATEWAY = 'host.lima.internal';
|
||||
|
||||
|
||||
+46
-2
@@ -244,9 +244,43 @@ function appendForwardedFor(current, remoteAddress) {
|
||||
return values.join(', ');
|
||||
}
|
||||
|
||||
function dependencyHealthRequest(target, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
method: 'GET',
|
||||
path: `${target.pathname}${target.search}`,
|
||||
headers: { connection: 'close' },
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
response.once('end', () => resolve(response.statusCode === 200));
|
||||
},
|
||||
);
|
||||
request.setTimeout(timeoutMs, () => request.destroy());
|
||||
request.once('error', () => resolve(false));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLoopbackHealthTarget(value, label) {
|
||||
const target = new URL(String(value ?? ''));
|
||||
if (
|
||||
target.protocol !== 'http:'
|
||||
|| !['127.0.0.1', 'localhost', '::1'].includes(target.hostname)
|
||||
) {
|
||||
throw new Error(`${label} must be a loopback http URL`);
|
||||
}
|
||||
target.hash = '';
|
||||
return target;
|
||||
}
|
||||
|
||||
export function createMemindCanaryProxy({
|
||||
stableTarget,
|
||||
candidateTarget,
|
||||
candidateHealthDependencies = [],
|
||||
policy,
|
||||
identityResolver,
|
||||
diagnosticSecret,
|
||||
@@ -256,6 +290,8 @@ export function createMemindCanaryProxy({
|
||||
} = {}) {
|
||||
const stable = normalizeLoopbackTarget(stableTarget, 'stable target');
|
||||
const candidate = normalizeLoopbackTarget(candidateTarget, 'candidate target');
|
||||
const candidateDependencies = candidateHealthDependencies.map((value, index) =>
|
||||
normalizeLoopbackHealthTarget(value, `candidate health dependency ${index + 1}`));
|
||||
if (!policy) throw new Error('canary proxy requires a routing policy');
|
||||
if (!identityResolver) throw new Error('canary proxy requires an identity resolver');
|
||||
if (!diagnosticSecret) throw new Error('canary proxy requires a diagnostic secret');
|
||||
@@ -269,8 +305,13 @@ export function createMemindCanaryProxy({
|
||||
return healthCache.healthy;
|
||||
}
|
||||
if (!healthPromise) {
|
||||
healthPromise = healthRequest(candidate, candidateHealthTimeoutMs, 'candidate')
|
||||
.then((healthy) => {
|
||||
healthPromise = Promise.all([
|
||||
healthRequest(candidate, candidateHealthTimeoutMs, 'candidate'),
|
||||
...candidateDependencies.map((target) =>
|
||||
dependencyHealthRequest(target, candidateHealthTimeoutMs)),
|
||||
])
|
||||
.then((results) => {
|
||||
const healthy = results.every(Boolean);
|
||||
healthCache = { checkedAt: Date.now(), healthy };
|
||||
return healthy;
|
||||
})
|
||||
@@ -539,6 +580,9 @@ async function main() {
|
||||
stableTarget: process.env.MEMIND_CANARY_STABLE_URL || 'http://127.0.0.1:8081',
|
||||
candidateTarget:
|
||||
process.env.MEMIND_CANARY_CANDIDATE_URL || 'http://127.0.0.1:18081',
|
||||
candidateHealthDependencies: csvValues(
|
||||
process.env.MEMIND_CANARY_CANDIDATE_HEALTH_URLS,
|
||||
),
|
||||
policy,
|
||||
identityResolver,
|
||||
diagnosticSecret: process.env.MEMIND_CANARY_DIAGNOSTIC_SECRET,
|
||||
|
||||
@@ -287,6 +287,54 @@ test('identity lookup errors fail closed to stable', async (t) => {
|
||||
assert.equal(response.headers['x-memind-runtime-route'], 'stable');
|
||||
});
|
||||
|
||||
test('candidate health fails closed when its DeepSeek compatibility dependency is down', async (t) => {
|
||||
const stableServer = upstream('stable');
|
||||
const candidateServer = upstream('candidate');
|
||||
let dependencyHealthy = true;
|
||||
const dependencyServer = http.createServer((req, res) => {
|
||||
res.writeHead(req.url === '/health' && dependencyHealthy ? 200 : 503);
|
||||
res.end();
|
||||
});
|
||||
const stableUrl = await listen(stableServer);
|
||||
const candidateUrl = await listen(candidateServer);
|
||||
const dependencyUrl = await listen(dependencyServer);
|
||||
const proxy = createMemindCanaryProxy({
|
||||
stableTarget: stableUrl,
|
||||
candidateTarget: candidateUrl,
|
||||
candidateHealthDependencies: [`${dependencyUrl}/health`],
|
||||
policy: createCanaryPolicy({ usernames: ['john'] }),
|
||||
identityResolver: {
|
||||
async fromSessionToken() {
|
||||
return { id: 'user-john', username: 'john' };
|
||||
},
|
||||
async fromWechatOpenid() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
diagnosticSecret: 'diagnostic-secret',
|
||||
candidateHealthTtlMs: 0,
|
||||
});
|
||||
const proxyUrl = await listen(proxy.server);
|
||||
t.after(async () => {
|
||||
await close(proxy.server);
|
||||
await close(stableServer);
|
||||
await close(candidateServer);
|
||||
await close(dependencyServer);
|
||||
});
|
||||
|
||||
const candidate = await request(proxyUrl, {
|
||||
headers: { cookie: 'tkmind_user_session=john-token' },
|
||||
});
|
||||
assert.equal(candidate.headers['x-memind-runtime-route'], 'candidate');
|
||||
|
||||
dependencyHealthy = false;
|
||||
const fallback = await request(proxyUrl, {
|
||||
headers: { cookie: 'tkmind_user_session=john-token' },
|
||||
});
|
||||
assert.equal(fallback.body, 'stable:');
|
||||
assert.equal(fallback.headers['x-memind-runtime-route'], 'stable');
|
||||
});
|
||||
|
||||
test('candidate health requires the explicit candidate runtime role', async (t) => {
|
||||
const stableServer = upstream('stable');
|
||||
const wrongRoleServer = upstream('stable');
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'server.mjs',
|
||||
'memind-canary-proxy.mjs',
|
||||
'deepseek-no-think-proxy.mjs',
|
||||
'wechat-mp.bundle.mjs',
|
||||
'mindspace-sandbox-mcp.mjs',
|
||||
'tkmind-search-mcp.mjs',
|
||||
@@ -16,6 +17,7 @@ export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'scripts/run-memind-portal-prod.sh',
|
||||
'scripts/run-memind-portal-candidate.sh',
|
||||
'scripts/run-memind-canary-proxy-prod.sh',
|
||||
'scripts/run-deepseek-compat-proxy-candidate.sh',
|
||||
'scripts/goosed-canary.compose.yml',
|
||||
'scripts/check-mindspace-public-links.mjs',
|
||||
'scripts/load-env.mjs',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startDeepseekNoThinkProxy } from '../deepseek-no-think-proxy.mjs';
|
||||
|
||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
test('sanitized production tool-round replay reaches DeepSeek with thinking disabled', async (t) => {
|
||||
const forwarded = [];
|
||||
const proxy = await startDeepseekNoThinkProxy({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
fetchImpl: async (_url, init) => {
|
||||
forwarded.push(JSON.parse(String(init.body)));
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { role: 'assistant', content: 'synthetic result' } }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
t.after(() => new Promise((resolve) => proxy.close(resolve)));
|
||||
const address = proxy.address();
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${address.port}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer synthetic-key',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-pro',
|
||||
thinking: { type: 'enabled' },
|
||||
messages: [
|
||||
{ role: 'user', content: 'Use the synthetic filesystem tool.' },
|
||||
{ role: 'assistant', tool_calls: [{ id: 'call-1', type: 'function' }] },
|
||||
{ role: 'tool', tool_call_id: 'call-1', content: 'synthetic file list' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(forwarded.length, 1);
|
||||
assert.deepEqual(forwarded[0].thinking, { type: 'disabled' });
|
||||
assert.equal(forwarded[0].messages[2].role, 'tool');
|
||||
});
|
||||
|
||||
test('Gate, artifact, candidate routing and rollback share one compatibility contract', async () => {
|
||||
const [
|
||||
builder,
|
||||
localStack,
|
||||
candidateRunner,
|
||||
compatRunner,
|
||||
canaryRelease,
|
||||
canaryRollback,
|
||||
] = await Promise.all([
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'release-gate', 'local-stack.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh'), 'utf8'),
|
||||
fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||
'utf8',
|
||||
),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'release-portal-canary-prod.sh'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'rollback-portal-canary-prod.sh'), 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(builder, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(
|
||||
localStack,
|
||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
||||
);
|
||||
assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/);
|
||||
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
||||
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
|
||||
assert.match(compatRunner, /export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1/);
|
||||
assert.match(canaryRelease, /run-deepseek-compat-proxy-candidate\.sh/);
|
||||
assert.match(canaryRelease, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
|
||||
assert.match(canaryRelease, /host\.docker\.internal:\$\{DEEPSEEK_COMPAT_PORT\}\/health/);
|
||||
assert.match(canaryRelease, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(canaryRollback, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
});
|
||||
@@ -380,7 +380,7 @@ export async function createLocalGateStack({
|
||||
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
|
||||
deepseekProxyChild = spawn(
|
||||
process.execPath,
|
||||
[path.join(root, 'deepseek-no-think-proxy.mjs')],
|
||||
[path.join(resolvedPortalRoot, 'deepseek-no-think-proxy.mjs')],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
|
||||
@@ -63,6 +63,16 @@ test('runtime builder materializes and declares required Linux ARM64 native pack
|
||||
assert.match(source, /缺少原生 \.node 文件/);
|
||||
});
|
||||
|
||||
test('runtime builder bundles the DeepSeek compatibility dependency into the artifact', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /bundleDeepseekCompatProxy/);
|
||||
assert.match(source, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(source, /await bundleDeepseekCompatProxy\(\)/);
|
||||
});
|
||||
|
||||
test('packaged runtime gate isolates persistent roots and rejects artifact mutation', async () => {
|
||||
const localStackSource = await fs.readFile(
|
||||
path.join(ROOT, 'release-gate', 'local-stack.mjs'),
|
||||
@@ -70,7 +80,10 @@ test('packaged runtime gate isolates persistent roots and rejects artifact mutat
|
||||
);
|
||||
assert.match(localStackSource, /MEMIND_PORTAL_H5_ROOT: sandboxRoot/);
|
||||
assert.match(localStackSource, /MEMIND_DEEPSEEK_DISABLE_THINKING: '1'/);
|
||||
assert.match(localStackSource, /deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(
|
||||
localStackSource,
|
||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
||||
);
|
||||
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_MODE: 'shadow'/);
|
||||
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1'/);
|
||||
assert.match(localStackSource, /H5_USERS_ROOT: usersRoot/);
|
||||
@@ -119,6 +132,9 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
const edgeBackup = source.indexOf('Create and verify the active 105 nginx routing backup');
|
||||
const goosedStart = source.indexOf('Start an isolated goosed candidate on 18015');
|
||||
const candidateStart = source.indexOf('Start the passive candidate Portal on 18081');
|
||||
const deepseekCompatStart = source.indexOf(
|
||||
'Start the DeepSeek tool-round compatibility proxy on 18036',
|
||||
);
|
||||
const proxyStart = source.indexOf('Start the fail-closed identity router on 18082');
|
||||
const tunnelStart = source.indexOf('Start the isolated 105 reverse tunnel on 19082');
|
||||
const edgeSwitch = source.indexOf(
|
||||
@@ -129,7 +145,8 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.ok(persistBackup > fullBackup);
|
||||
assert.ok(edgeBackup > persistBackup);
|
||||
assert.ok(goosedStart > edgeBackup);
|
||||
assert.ok(candidateStart > goosedStart);
|
||||
assert.ok(deepseekCompatStart > goosedStart);
|
||||
assert.ok(candidateStart > deepseekCompatStart);
|
||||
assert.ok(proxyStart > candidateStart);
|
||||
assert.ok(tunnelStart > proxyStart);
|
||||
assert.ok(edgeSwitch > tunnelStart);
|
||||
@@ -140,6 +157,12 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.match(source, /nginx -t/);
|
||||
assert.match(source, /CANARY_PROXY_PORT=18082/);
|
||||
assert.match(source, /CANARY_TUNNEL_REMOTE_PORT=19082/);
|
||||
assert.match(source, /DEEPSEEK_COMPAT_PORT=18036/);
|
||||
assert.match(source, /deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(source, /run-deepseek-compat-proxy-candidate\.sh/);
|
||||
assert.match(source, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
|
||||
assert.match(source, /deepseek_compat_healthy/);
|
||||
assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(
|
||||
source,
|
||||
/edge_ssh\(\) \{\s*#.*\n\s*#.*\n\s*ssh -n -o BatchMode=yes/,
|
||||
@@ -176,6 +199,11 @@ test('candidate runner overrides stable host MCP paths with container-visible pa
|
||||
[
|
||||
'GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node',
|
||||
'GOOSED_MCP_SERVER_PATH=/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs',
|
||||
'MEMIND_DEEPSEEK_DISABLE_THINKING=0',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_PORT=9999',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_BASE_URL=http://wrong.invalid/v1',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_HOST=wrong.invalid',
|
||||
'MEMIND_GOOSED_HOST_GATEWAY=wrong.invalid',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
@@ -186,6 +214,11 @@ test('candidate runner overrides stable host MCP paths with container-visible pa
|
||||
'printf "cwd=%s\\n" "$PWD"',
|
||||
'printf "mcp_node=%s\\n" "$GOOSED_MCP_NODE_PATH"',
|
||||
'printf "mcp_server=%s\\n" "$GOOSED_MCP_SERVER_PATH"',
|
||||
'printf "deepseek_disable=%s\\n" "$MEMIND_DEEPSEEK_DISABLE_THINKING"',
|
||||
'printf "deepseek_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"',
|
||||
'printf "deepseek_gateway=%s\\n" "$MEMIND_GOOSED_HOST_GATEWAY"',
|
||||
'printf "deepseek_base=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_BASE_URL-unset}"',
|
||||
'printf "deepseek_host=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_HOST-unset}"',
|
||||
'printf "entrypoint=%s\\n" "$1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
@@ -215,9 +248,78 @@ test('candidate runner overrides stable host MCP paths with container-visible pa
|
||||
result.stdout,
|
||||
/^mcp_server=\/opt\/portal\/mindspace-sandbox-mcp\.mjs$/m,
|
||||
);
|
||||
assert.match(result.stdout, /^deepseek_disable=1$/m);
|
||||
assert.match(result.stdout, /^deepseek_port=18036$/m);
|
||||
assert.match(result.stdout, /^deepseek_gateway=host\.docker\.internal$/m);
|
||||
assert.match(result.stdout, /^deepseek_base=unset$/m);
|
||||
assert.match(result.stdout, /^deepseek_host=unset$/m);
|
||||
assert.match(result.stdout, new RegExp(`^entrypoint=${ROOT}/server\\.mjs$`, 'm'));
|
||||
});
|
||||
|
||||
test('canary rollback stops the DeepSeek compatibility process with the other canary services', async () => {
|
||||
const source = await fs.readFile(CANARY_ROLLBACK, 'utf8');
|
||||
assert.match(
|
||||
source,
|
||||
/DEEPSEEK_COMPAT_LABEL="cn\.tkmind\.memind-deepseek-compat-candidate"/,
|
||||
);
|
||||
assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(source, /docker rm -f goosed-prod-canary/);
|
||||
});
|
||||
|
||||
test('DeepSeek compatibility runner preserves stable upstream config but enforces candidate controls', async (t) => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-deepseek-runner-'));
|
||||
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
|
||||
|
||||
const stableRoot = path.join(tempRoot, 'stable');
|
||||
const fakeNode = path.join(tempRoot, 'node');
|
||||
await fs.mkdir(stableRoot);
|
||||
await fs.writeFile(
|
||||
path.join(stableRoot, '.env'),
|
||||
[
|
||||
'DEEPSEEK_API_BASE_URL=https://synthetic-upstream.invalid/v1',
|
||||
'MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=0',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_PORT=9999',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakeNode,
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'printf "proxy_entrypoint=%s\\n" "$MEMIND_DEEPSEEK_PROXY_ENTRYPOINT"',
|
||||
'printf "proxy_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"',
|
||||
'printf "upstream=%s\\n" "$DEEPSEEK_API_BASE_URL"',
|
||||
'printf "entrypoint=%s\\n" "$1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh')],
|
||||
{
|
||||
cwd: '/',
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MEMIND_CANARY_STABLE_ROOT: stableRoot,
|
||||
MEMIND_CANARY_DEEPSEEK_PROXY_PORT: '18036',
|
||||
NODE_BIN: fakeNode,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /^proxy_entrypoint=1$/m);
|
||||
assert.match(result.stdout, /^proxy_port=18036$/m);
|
||||
assert.match(result.stdout, /^upstream=https:\/\/synthetic-upstream\.invalid\/v1$/m);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
new RegExp(`^entrypoint=${ROOT}/deepseek-no-think-proxy\\.mjs$`, 'm'),
|
||||
);
|
||||
});
|
||||
|
||||
test('canary release and rollback remote shells remain syntactically valid', async () => {
|
||||
const releaseSource = await fs.readFile(CANARY_RELEASE, 'utf8');
|
||||
const rollbackSource = await fs.readFile(CANARY_ROLLBACK, 'utf8');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "PRC-REL-002",
|
||||
"scenario_ids": ["REL-06", "REL-09", "REL-11"],
|
||||
"failure_signature": "gate-production-drift-deepseek-thinking-tool-round-reasoning-content-replay-rejected",
|
||||
"synthetic_intent": "验证工具调用后的合成 DeepSeek 会话经过生产候选兼容链路,禁用思考模式,并在兼容依赖失效时让灰度流量回落稳定版本。",
|
||||
"source_batch_id": "incident-20260726-deepseek-tool-round",
|
||||
"evidence_digest": "97171fe1d94e0297cb0b932cf1ff18e8d1760519300f7308b9f8cc4e9517e022",
|
||||
"contains_personal_data": false,
|
||||
"privacy_review": {
|
||||
"reviewed_by": "release-gate-maintainers",
|
||||
"reviewed_at": "2026-07-26T23:55:00+08:00"
|
||||
},
|
||||
"automation": {
|
||||
"command": ["node", "--test", "release-gate/deepseek-production-parity.test.mjs"]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_ids": ["PRC-REL-001"],
|
||||
"fixture_ids": ["PRC-REL-001", "PRC-REL-002"],
|
||||
"reviewed_by": "release-gate-maintainers",
|
||||
"reviewed_at": "2026-07-26T12:00:00+08:00",
|
||||
"notes": "仅收录已脱敏的 runtime 启动回归签名;不包含聊天原文、用户身份或生产地址。"
|
||||
"notes": "仅收录已脱敏的 runtime 启动和生产配置同构回归签名;不包含聊天原文、用户身份或生产地址。"
|
||||
}
|
||||
|
||||
@@ -156,6 +156,26 @@ async function bundleCanaryProxy() {
|
||||
await run(esbuildBin, args);
|
||||
}
|
||||
|
||||
async function bundleDeepseekCompatProxy() {
|
||||
if (!(await exists(esbuildBin))) {
|
||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||
}
|
||||
console.log('==> 打包 DeepSeek 工具轮次兼容代理');
|
||||
const args = [
|
||||
'deepseek-no-think-proxy.mjs',
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=esm',
|
||||
`--target=${runtimeNodeTarget}`,
|
||||
'--outfile=.runtime/portal/deepseek-no-think-proxy.mjs',
|
||||
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
];
|
||||
for (const pkg of externalPackages) {
|
||||
args.push(`--external:${pkg}`);
|
||||
}
|
||||
await run(esbuildBin, args);
|
||||
}
|
||||
|
||||
async function bundleSandboxMcp() {
|
||||
if (!(await exists(esbuildBin))) {
|
||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||
@@ -409,6 +429,7 @@ async function writeMetadata() {
|
||||
for (const scriptName of [
|
||||
'run-memind-portal-candidate.sh',
|
||||
'run-memind-canary-proxy-prod.sh',
|
||||
'run-deepseek-compat-proxy-candidate.sh',
|
||||
]) {
|
||||
const source = path.join(root, 'scripts', scriptName);
|
||||
if (!(await exists(source))) {
|
||||
@@ -527,6 +548,7 @@ async function writeMetadata() {
|
||||
' tkmind-excel-mcp.mjs (esbuild bundle; required when Excel Analyst is enabled)',
|
||||
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
|
||||
' memind-canary-proxy.mjs (immutable-identity stable/candidate router)',
|
||||
' deepseek-no-think-proxy.mjs (DeepSeek V4 tool-round compatibility proxy)',
|
||||
'',
|
||||
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
||||
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
|
||||
@@ -598,6 +620,7 @@ async function main() {
|
||||
await copyRuntimeAssets();
|
||||
await bundleServer();
|
||||
await bundleCanaryProxy();
|
||||
await bundleDeepseekCompatProxy();
|
||||
await bundleWechatMp();
|
||||
await bundleSandboxMcp();
|
||||
await bundleMindSearchMcp();
|
||||
@@ -611,6 +634,10 @@ async function main() {
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-candidate.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-canary-proxy-prod.sh'), 0o755);
|
||||
await fs.chmod(
|
||||
path.join(runtimeRoot, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||
0o755,
|
||||
);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755);
|
||||
|
||||
@@ -13,9 +13,11 @@ RUNTIME_ROOT="${ROOT}/.runtime/portal"
|
||||
PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate"
|
||||
CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy"
|
||||
CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel"
|
||||
DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate"
|
||||
EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}"
|
||||
CANARY_PROXY_PORT=18082
|
||||
CANARY_TUNNEL_REMOTE_PORT=19082
|
||||
DEEPSEEK_COMPAT_PORT=18036
|
||||
EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf"
|
||||
EDGE_WECHAT_CONFIG="/etc/nginx/conf.d/wechat.m.tkmind.cn.conf"
|
||||
CANARY_USERNAMES="john"
|
||||
@@ -46,6 +48,7 @@ Deploys a candidate Portal beside the stable 103 runtime:
|
||||
canary router 127.0.0.1:18082
|
||||
candidate Portal 127.0.0.1:18081
|
||||
candidate goosed 127.0.0.1:18015
|
||||
DeepSeek compat 0.0.0.0:18036 (host/container only)
|
||||
|
||||
The stable runtime is not replaced. A dedicated reverse tunnel exposes the
|
||||
router only to 105 at 127.0.0.1:19082. The committed release workflow updates
|
||||
@@ -123,6 +126,7 @@ say "Run release source guards"
|
||||
required_runtime_paths=(
|
||||
server.mjs
|
||||
memind-canary-proxy.mjs
|
||||
deepseek-no-think-proxy.mjs
|
||||
wechat-mp.bundle.mjs
|
||||
mindspace-sandbox-mcp.mjs
|
||||
tkmind-search-mcp.mjs
|
||||
@@ -131,6 +135,7 @@ required_runtime_paths=(
|
||||
package.json
|
||||
scripts/run-memind-portal-candidate.sh
|
||||
scripts/run-memind-canary-proxy-prod.sh
|
||||
scripts/run-deepseek-compat-proxy-candidate.sh
|
||||
scripts/goosed-canary.compose.yml
|
||||
scripts/memind-portal-tunnel.sh
|
||||
)
|
||||
@@ -155,7 +160,7 @@ printf '%s %s\n' "${bundle_sha}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
|
||||
echo "git_branch=${branch}"
|
||||
echo "artifact_tree=.runtime/portal"
|
||||
echo "artifact_bundle_sha256=${bundle_sha}"
|
||||
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015"
|
||||
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036"
|
||||
echo "canary_usernames=${CANARY_USERNAMES}"
|
||||
echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}"
|
||||
echo "manual_environment_changes=none"
|
||||
@@ -174,6 +179,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
&& test -f '${GOOSED_DIR}/docker-compose.prod.yml' \
|
||||
&& curl -fsS http://127.0.0.1:8081/api/status >/dev/null \
|
||||
&& ! lsof -nP -iTCP:${CANARY_PROXY_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& ! lsof -nP -iTCP:18081 -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& ! lsof -nP -iTCP:18015 -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& /opt/homebrew/bin/docker inspect goosed-prod-1 >/dev/null \
|
||||
@@ -214,9 +220,11 @@ ssh -o BatchMode=yes "${HOST}" \
|
||||
PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \
|
||||
CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \
|
||||
CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \
|
||||
DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \
|
||||
EDGE_HOST='${EDGE_HOST}' \
|
||||
CANARY_PROXY_PORT='${CANARY_PROXY_PORT}' \
|
||||
CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \
|
||||
DEEPSEEK_COMPAT_PORT='${DEEPSEEK_COMPAT_PORT}' \
|
||||
EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \
|
||||
EDGE_WECHAT_CONFIG='${EDGE_WECHAT_CONFIG}' \
|
||||
CANARY_USERNAMES='${CANARY_USERNAMES}' \
|
||||
@@ -234,6 +242,7 @@ PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before-canary.t
|
||||
CANDIDATE_PLIST="${HOME}/Library/LaunchAgents/${PORTAL_CANDIDATE_LABEL}.plist"
|
||||
PROXY_PLIST="${HOME}/Library/LaunchAgents/${CANARY_PROXY_LABEL}.plist"
|
||||
CANARY_TUNNEL_PLIST="${HOME}/Library/LaunchAgents/${CANARY_TUNNEL_LABEL}.plist"
|
||||
DEEPSEEK_COMPAT_PLIST="${HOME}/Library/LaunchAgents/${DEEPSEEK_COMPAT_LABEL}.plist"
|
||||
SECRET_FILE="${HOME}/.config/memind/canary-router.secret"
|
||||
DOCKER_BIN="/opt/homebrew/bin/docker"
|
||||
GOOSED_COMPOSE="${GOOSED_DIR}/docker-compose.prod.yml"
|
||||
@@ -260,6 +269,22 @@ candidate_healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
deepseek_compat_healthy() {
|
||||
curl -fsS --max-time 5 "http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health" \
|
||||
| /opt/homebrew/opt/node@24/bin/node --input-type=module -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const status = JSON.parse(input);
|
||||
if (
|
||||
status.ok !== true
|
||||
|| status.contractVersion !== 1
|
||||
|| status.deepseekThinking !== "disabled"
|
||||
) process.exit(1);
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
edge_ssh() {
|
||||
# This remote release script itself arrives on stdin. Ordinary nested SSH
|
||||
# commands must not consume the unparsed remainder of that script.
|
||||
@@ -326,6 +351,7 @@ stop_candidate_services() {
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
|
||||
"${DOCKER_BIN}" rm -f goosed-prod-canary >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -405,7 +431,8 @@ tar -xzf "${BUNDLE}" -C "${CANDIDATE_DIR}"
|
||||
cp "${MANIFEST}" "${CANDIDATE_DIR}/.release-manifest.txt"
|
||||
chmod 755 \
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh" \
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh"
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh" \
|
||||
"${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh"
|
||||
|
||||
say "Stop an older canary without touching stable Portal 8081"
|
||||
restore_edge_to_stable
|
||||
@@ -434,6 +461,39 @@ done
|
||||
"${DOCKER_BIN}" exec goosed-prod-canary \
|
||||
sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs'
|
||||
|
||||
say "Start the DeepSeek tool-round compatibility proxy on 18036"
|
||||
cat > "${DEEPSEEK_COMPAT_PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>${DEEPSEEK_COMPAT_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh</string></array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
|
||||
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>ThrottleInterval</key><integer>5</integer>
|
||||
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
|
||||
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap "${LAUNCHD_GUI}" "${DEEPSEEK_COMPAT_PLIST}" >/dev/null
|
||||
launchctl kickstart -k "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null
|
||||
for _ in $(seq 1 30); do
|
||||
deepseek_compat_healthy && break
|
||||
sleep 1
|
||||
done
|
||||
deepseek_compat_healthy
|
||||
"${DOCKER_BIN}" exec goosed-prod-canary \
|
||||
sh -lc "curl -fsS --max-time 5 http://host.docker.internal:${DEEPSEEK_COMPAT_PORT}/health \
|
||||
| grep -q '\"deepseekThinking\":\"disabled\"'"
|
||||
|
||||
say "Start the passive candidate Portal on 18081"
|
||||
cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -449,6 +509,7 @@ cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
<key>MEMIND_CANARY_RELEASE_ID</key><string>${RELEASE_ID}</string>
|
||||
<key>MEMIND_CANARY_CANDIDATE_PORT</key><string>18081</string>
|
||||
<key>MEMIND_CANARY_GOOSED_URL</key><string>https://127.0.0.1:18015</string>
|
||||
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
@@ -500,6 +561,7 @@ cat > "${PROXY_PLIST}" <<EOF
|
||||
<key>MEMIND_CANARY_PROXY_PORT</key><string>${CANARY_PROXY_PORT}</string>
|
||||
<key>MEMIND_RELEASE_CANARY_USERNAMES</key><string>${CANARY_USERNAMES}</string>
|
||||
<key>MEMIND_RELEASE_CANARY_WECHAT_USER_IDS</key><string>${CANARY_WECHAT_USER_IDS}</string>
|
||||
<key>MEMIND_CANARY_CANDIDATE_HEALTH_URLS</key><string>http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
@@ -675,6 +737,7 @@ printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PRO
|
||||
printf 'edge_tunnel=http://127.0.0.1:%s/api/status\n' "${CANARY_TUNNEL_REMOTE_PORT}"
|
||||
printf 'candidate_health=http://127.0.0.1:18081/api/status\n'
|
||||
printf 'candidate_goosed=https://127.0.0.1:18015/status\n'
|
||||
printf 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
say "103 canary release completed"
|
||||
|
||||
@@ -166,7 +166,7 @@ fi
|
||||
|
||||
verify_runtime_artifact() {
|
||||
local missing=0
|
||||
for required in server.mjs memind-canary-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||
missing=1
|
||||
|
||||
@@ -6,6 +6,7 @@ STABLE_DIR="${STUDIO_REMOTE_ROOT:-/Users/john/Project}/Memind"
|
||||
PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate"
|
||||
CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy"
|
||||
CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel"
|
||||
DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate"
|
||||
EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}"
|
||||
CANARY_TUNNEL_REMOTE_PORT=19082
|
||||
EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf"
|
||||
@@ -30,6 +31,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \
|
||||
CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \
|
||||
CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \
|
||||
DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \
|
||||
EDGE_HOST='${EDGE_HOST}' \
|
||||
CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \
|
||||
EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \
|
||||
@@ -69,6 +71,7 @@ EDGE_ROLLBACK
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
|
||||
/opt/homebrew/bin/docker rm -f goosed-prod-canary >/dev/null 2>&1 || true
|
||||
rm -f "${STABLE_DIR}/.release-drain"
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
STABLE_ROOT="${MEMIND_CANARY_STABLE_ROOT:-/Users/john/Project/Memind}"
|
||||
|
||||
if [[ ! -d "${STABLE_ROOT}" || ! -f "${STABLE_ROOT}/.env" ]]; then
|
||||
echo "[deepseek-compat] stable runtime environment is unavailable: ${STABLE_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${STABLE_ROOT}/.env"
|
||||
set +a
|
||||
|
||||
export NODE_ENV=production
|
||||
export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1
|
||||
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
exec "${NODE_BIN}" "${ROOT}/deepseek-no-think-proxy.mjs"
|
||||
@@ -33,6 +33,13 @@ export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}"
|
||||
# mounted at /opt/portal. Never inherit host-only MCP paths from the stable .env.
|
||||
export GOOSED_MCP_NODE_PATH=/usr/local/bin/node
|
||||
export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
|
||||
# DeepSeek V4 tool rounds must use the same compatibility contract exercised
|
||||
# by the release Gate. Stable .env values cannot disable or redirect it.
|
||||
export MEMIND_DEEPSEEK_DISABLE_THINKING=1
|
||||
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}"
|
||||
export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
|
||||
unset MEMIND_DEEPSEEK_NO_THINK_BASE_URL
|
||||
unset MEMIND_DEEPSEEK_NO_THINK_HOST
|
||||
unset TKMIND_API_TARGET_1
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
|
||||
Reference in New Issue
Block a user