mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-12 17:50:45 +08:00
fix(weixin): refine Harness access errors
This commit is contained in:
parent
ae49413871
commit
83ee0d63a7
7 changed files with 194 additions and 135 deletions
176
lib/index.js
176
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -8,6 +8,55 @@ import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
|
||||||
// Harness origin prevents two channel-specific clients bound to one Session
|
// Harness origin prevents two channel-specific clients bound to one Session
|
||||||
// from claiming or cancelling each other's interactions.
|
// from claiming or cancelling each other's interactions.
|
||||||
const interactionRegistries = new Map();
|
const interactionRegistries = new Map();
|
||||||
|
const MAX_ERROR_CLASSIFICATION_BYTES = 64;
|
||||||
|
|
||||||
|
async function smallResponseText(response) {
|
||||||
|
const stream = response?.body;
|
||||||
|
if (!stream || typeof stream.getReader !== 'function') return null;
|
||||||
|
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const chunks = [];
|
||||||
|
let length = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!(value instanceof Uint8Array)
|
||||||
|
|| length + value.byteLength > MAX_ERROR_CLASSIFICATION_BYTES) return null;
|
||||||
|
chunks.push(value);
|
||||||
|
length += value.byteLength;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// The response body is diagnostic-only; cancellation failures do not
|
||||||
|
// replace the HTTP status that caused the transport error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function harnessHttpErrorCode(response) {
|
||||||
|
if (response.status === 401) return 'harness-auth-required';
|
||||||
|
if (response.status === 403) {
|
||||||
|
const body = await smallResponseText(response);
|
||||||
|
return body?.trim() === 'forbidden'
|
||||||
|
? 'harness-host-untrusted'
|
||||||
|
: 'harness-request-forbidden';
|
||||||
|
}
|
||||||
|
if (response.status === 404) return 'harness-api-not-found';
|
||||||
|
return 'harness-http-failed';
|
||||||
|
}
|
||||||
|
|
||||||
function interactionRegistry(origin) {
|
function interactionRegistry(origin) {
|
||||||
let registry = interactionRegistries.get(origin);
|
let registry = interactionRegistries.get(origin);
|
||||||
|
|
@ -494,11 +543,7 @@ export class HarnessClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const code = response.status === 401 || response.status === 403
|
const code = await harnessHttpErrorCode(response);
|
||||||
? 'harness-access-denied'
|
|
||||||
: response.status === 404
|
|
||||||
? 'harness-api-not-found'
|
|
||||||
: 'harness-http-failed';
|
|
||||||
throw new HarnessTransportError(code, method, { status: response.status });
|
throw new HarnessTransportError(code, method, { status: response.status });
|
||||||
}
|
}
|
||||||
let body;
|
let body;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ const ACTIVATION_ERROR_MESSAGES = Object.freeze({
|
||||||
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
||||||
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
||||||
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
||||||
'harness-access-denied': '微信已授权,但 Harness 拒绝了本机健康检查。请检查 Host 信任配置。',
|
'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
|
||||||
|
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了本机请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
|
||||||
|
'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
|
||||||
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
||||||
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
||||||
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ const DEFAULT_START_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000]);
|
||||||
const HARNESS_HEALTH_ERROR_CODES = new Set([
|
const HARNESS_HEALTH_ERROR_CODES = new Set([
|
||||||
'harness-connect-failed',
|
'harness-connect-failed',
|
||||||
'harness-timeout',
|
'harness-timeout',
|
||||||
'harness-access-denied',
|
'harness-auth-required',
|
||||||
|
'harness-host-untrusted',
|
||||||
|
'harness-request-forbidden',
|
||||||
'harness-api-not-found',
|
'harness-api-not-found',
|
||||||
'harness-http-failed',
|
'harness-http-failed',
|
||||||
'harness-response-invalid',
|
'harness-response-invalid',
|
||||||
|
|
|
||||||
|
|
@ -79,18 +79,20 @@ test('shared Harness health checks expose precise safe availability codes', asyn
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [status, expectedCode] of [
|
for (const [status, responseBody, expectedCode] of [
|
||||||
[401, 'harness-access-denied'],
|
[401, 'authentication required', 'harness-auth-required'],
|
||||||
[403, 'harness-access-denied'],
|
[403, 'forbidden', 'harness-host-untrusted'],
|
||||||
[404, 'harness-api-not-found'],
|
[403, 'proxy policy rejected: private detail', 'harness-request-forbidden'],
|
||||||
[500, 'harness-http-failed'],
|
[404, 'not found', 'harness-api-not-found'],
|
||||||
|
[500, 'service unavailable', 'harness-http-failed'],
|
||||||
]) {
|
]) {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
clientWithFetch(async () => ({ ok: false, status })).health(),
|
clientWithFetch(async () => new Response(responseBody, { status })).health(),
|
||||||
(error) => {
|
(error) => {
|
||||||
assert.ok(error instanceof HarnessTransportError);
|
assert.ok(error instanceof HarnessTransportError);
|
||||||
assert.equal(error.code, expectedCode);
|
assert.equal(error.code, expectedCode);
|
||||||
assert.equal(error.status, status);
|
assert.equal(error.status, status);
|
||||||
|
assert.doesNotMatch(error.message, /private detail/);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -347,41 +347,47 @@ test('account config write and runtime preparation failures have distinct safe c
|
||||||
});
|
});
|
||||||
|
|
||||||
test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
|
test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
|
||||||
const credentials = credentialsFixture();
|
for (const scenario of [
|
||||||
const configs = configFixture();
|
['harness-auth-required', /需要身份认证/],
|
||||||
const runtimes = runtimeFactory({
|
['harness-host-untrusted', /Host 信任检查/],
|
||||||
startError: Object.assign(new Error('loopback transport host-only detail'), {
|
['harness-request-forbidden', /代理或网关配置/],
|
||||||
code: 'harness-api-not-found',
|
['harness-api-not-found', /找不到 Harness 健康检查接口/],
|
||||||
}),
|
]) {
|
||||||
});
|
const [code, publicMessage] = scenario;
|
||||||
const controller = new WeixinController({
|
const credentials = credentialsFixture();
|
||||||
api: {
|
const configs = configFixture();
|
||||||
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
const runtimes = runtimeFactory({
|
||||||
pollLogin: async () => ({
|
startError: Object.assign(new Error(`host-only detail for ${code}`), { code }),
|
||||||
status: 'confirmed',
|
});
|
||||||
bot_token: 'must-be-rolled-back',
|
const controller = new WeixinController({
|
||||||
ilink_bot_id: 'harness-failure@im.bot',
|
api: {
|
||||||
ilink_user_id: 'owner',
|
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
|
||||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
pollLogin: async () => ({
|
||||||
}),
|
status: 'confirmed',
|
||||||
},
|
bot_token: 'must-be-rolled-back',
|
||||||
credentials: credentials.provider,
|
ilink_bot_id: `${code}@im.bot`,
|
||||||
configStore: configs.store,
|
ilink_user_id: 'owner',
|
||||||
createRuntime: runtimes.createRuntime,
|
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||||
logger: { error() {}, warn() {} },
|
}),
|
||||||
});
|
},
|
||||||
|
credentials: credentials.provider,
|
||||||
|
configStore: configs.store,
|
||||||
|
createRuntime: runtimes.createRuntime,
|
||||||
|
logger: { error() {}, warn() {} },
|
||||||
|
});
|
||||||
|
|
||||||
const begun = await controller.startProvisioning();
|
const begun = await controller.startProvisioning();
|
||||||
const failed = await waitFor(
|
const failed = await waitFor(
|
||||||
() => controller.registrationStatus(begun.attemptId),
|
() => controller.registrationStatus(begun.attemptId),
|
||||||
(value) => value.status === 'failed',
|
(value) => value.status === 'failed',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(failed.error.code, 'harness-api-not-found');
|
assert.equal(failed.error.code, code);
|
||||||
assert.notEqual(failed.error.code, 'harness-unreachable');
|
assert.notEqual(failed.error.code, 'harness-unreachable');
|
||||||
assert.match(failed.error.message, /找不到 Harness 健康检查接口/);
|
assert.match(failed.error.message, publicMessage);
|
||||||
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
|
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
|
||||||
await controller.close();
|
await controller.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unclassified activation error uses the explicit unknown fallback code', async () => {
|
test('an unclassified activation error uses the explicit unknown fallback code', async () => {
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,9 @@ test('runtime preserves classified Harness health codes without exposing their c
|
||||||
for (const code of [
|
for (const code of [
|
||||||
'harness-connect-failed',
|
'harness-connect-failed',
|
||||||
'harness-timeout',
|
'harness-timeout',
|
||||||
'harness-access-denied',
|
'harness-auth-required',
|
||||||
|
'harness-host-untrusted',
|
||||||
|
'harness-request-forbidden',
|
||||||
'harness-api-not-found',
|
'harness-api-not-found',
|
||||||
'harness-http-failed',
|
'harness-http-failed',
|
||||||
'harness-response-invalid',
|
'harness-response-invalid',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue