@hirey/hi-mcp-server 0.1.23 → 0.1.25
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 +20 -0
- package/dist/defaultReplyRoute.d.ts +4 -2
- package/dist/defaultReplyRoute.d.ts.map +1 -1
- package/dist/defaultReplyRoute.js +3 -0
- package/dist/installDefaults.d.ts +2 -1
- package/dist/installDefaults.d.ts.map +1 -1
- package/dist/installDefaults.js +14 -3
- package/dist/oauthRequestAuth.d.ts +67 -0
- package/dist/oauthRequestAuth.d.ts.map +1 -0
- package/dist/oauthRequestAuth.js +117 -0
- package/dist/receiver-command.d.ts +2 -1
- package/dist/receiver-command.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +889 -72
- package/dist/state.d.ts +2 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +25 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
4
5
|
import fsSync from 'node:fs';
|
|
5
6
|
import { createServer } from 'node:http';
|
|
@@ -17,16 +18,79 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
17
18
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
18
19
|
import { AGENT_GATEWAY_EVENT_TOPICS, normalizeAgentEndpointList, normalizeAgentInstallationDeliveryDeclaration, normalizeAgentSubscriptionList, normalizeText, INSTALL_WELCOME_ONBOARDING_KIND, INSTALL_WELCOME_ONBOARDING_INSTRUCTION, DEFAULT_INTENT_OPTIONS, } from '@hirey/hi-agent-contracts';
|
|
19
20
|
import { createHiAgentClients, exchangeHiAgentClientCredentialsToken, HiAgentGatewayClient, HiAgentPlatformClient, } from '@hirey/hi-agent-sdk';
|
|
20
|
-
import { quarantineStaleIdentityIfNeeded, readState, resolveCanonicalOpenClawStateDir, resolveDefaultStateDir, resolveLegacyStateFiles, resolveStateFile, validateOpenClawStateDir, updateState, normalizeStateProfile, } from './state.js';
|
|
21
|
+
import { composeSubjectScopedProfile, quarantineStaleIdentityIfNeeded, readState, resolveCanonicalOpenClawStateDir, resolveDefaultStateDir, resolveLegacyStateFiles, resolveStateFile, validateOpenClawStateDir, updateState, normalizeStateProfile, } from './state.js';
|
|
21
22
|
import { looksLikeOpenClawSessionKey, validateOpenClawSessionKey, } from './openclaw-session-key.js';
|
|
22
23
|
import { buildInstallReceiverCommandArgv, } from './receiver-command.js';
|
|
23
24
|
import { applyReceiverRuntimeSnapshot, receiverConfigMaterialEquals, } from './receiver-config-material.js';
|
|
24
25
|
import { resolveInstallDefaultReplyDeliveryContext, resolveInstallRouteMissingPolicy, } from './defaultReplyRoute.js';
|
|
25
26
|
import { resolveInstallDisplayName } from './installDefaults.js';
|
|
27
|
+
import { buildAuthorizationServerMetadataAlias, buildOAuthVerifier, buildProtectedResourceMetadata, buildWwwAuthenticateChallenge, getCurrentRequestAuth, requestAuthStorage, verifyRequestBearer, } from './oauthRequestAuth.js';
|
|
28
|
+
// Hosts that hi-mcp-server can claim a first-class identity for. Everything
|
|
29
|
+
// outside this set normalizes down to `generic`. We carry the wider list here
|
|
30
|
+
// (not the narrow openclaw/generic split) so install state + telemetry can
|
|
31
|
+
// distinguish a Codex install from a Claude Code install from a bare generic
|
|
32
|
+
// MCP host, even though the install flow itself only branches on openclaw.
|
|
33
|
+
const KNOWN_HOST_KINDS = new Set([
|
|
34
|
+
'openclaw',
|
|
35
|
+
'generic',
|
|
36
|
+
'codex',
|
|
37
|
+
'claude_code',
|
|
38
|
+
'claude_chat',
|
|
39
|
+
'claude_cowork',
|
|
40
|
+
'claude_desktop',
|
|
41
|
+
'chatgpt_app',
|
|
42
|
+
]);
|
|
26
43
|
const CAPABILITY_CACHE_TTL_MS = 30_000;
|
|
27
44
|
const RECEIVER_STOP_TIMEOUT_MS = 3_000;
|
|
28
45
|
const RECEIVER_STOP_POLL_MS = 100;
|
|
29
46
|
const resolvedProfile = normalizeStateProfile(process.env.HI_MCP_PROFILE);
|
|
47
|
+
const requestToolSurfaceStorage = new AsyncLocalStorage();
|
|
48
|
+
const CHATGPT_APP_TOOL_NAMES = new Set([
|
|
49
|
+
'listing_taxonomy',
|
|
50
|
+
'agent_listings',
|
|
51
|
+
'matching_sessions',
|
|
52
|
+
'pairings',
|
|
53
|
+
'thread_meetings',
|
|
54
|
+
'faq_search',
|
|
55
|
+
'faq_get',
|
|
56
|
+
'content_render',
|
|
57
|
+
'content_get',
|
|
58
|
+
]);
|
|
59
|
+
const CAPABILITY_TOOL_OUTPUT_SCHEMA = {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
ok: { type: 'boolean' },
|
|
63
|
+
capability_id: { type: 'string' },
|
|
64
|
+
tool_name: { type: 'string' },
|
|
65
|
+
result: {
|
|
66
|
+
type: ['object', 'array', 'string', 'number', 'boolean', 'null'],
|
|
67
|
+
description: 'Tool-specific result payload returned by Hi.',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ['ok', 'capability_id', 'tool_name', 'result'],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
};
|
|
73
|
+
function normalizeToolSurface(input) {
|
|
74
|
+
const value = normalizeText(input).toLowerCase().replace(/[\s-]+/g, '_');
|
|
75
|
+
if (['chatgpt', 'chatgpt_app', 'chatgpt_apps', 'openai_app', 'apps'].includes(value)) {
|
|
76
|
+
return 'chatgpt_app';
|
|
77
|
+
}
|
|
78
|
+
return 'full';
|
|
79
|
+
}
|
|
80
|
+
function effectiveToolSurface() {
|
|
81
|
+
return requestToolSurfaceStorage.getStore() || config.toolSurface;
|
|
82
|
+
}
|
|
83
|
+
function isToolVisibleOnSurface(toolName) {
|
|
84
|
+
if (effectiveToolSurface() === 'full')
|
|
85
|
+
return true;
|
|
86
|
+
return CHATGPT_APP_TOOL_NAMES.has(toolName);
|
|
87
|
+
}
|
|
88
|
+
function readListEnv(name, fallback) {
|
|
89
|
+
const raw = normalizeText(process.env[name]);
|
|
90
|
+
if (!raw)
|
|
91
|
+
return fallback;
|
|
92
|
+
return raw.split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
93
|
+
}
|
|
30
94
|
const config = {
|
|
31
95
|
host: normalizeText(process.env.HI_MCP_HOST) || '127.0.0.1',
|
|
32
96
|
port: Number(process.env.HI_MCP_PORT || 8788),
|
|
@@ -34,6 +98,19 @@ const config = {
|
|
|
34
98
|
stateDir: normalizeText(process.env.HI_MCP_STATE_DIR) || resolveDefaultStateDir(resolvedProfile),
|
|
35
99
|
platformBaseUrl: normalizeText(process.env.HI_PLATFORM_BASE_URL),
|
|
36
100
|
transport: normalizeText(process.env.HI_MCP_TRANSPORT).toLowerCase() === 'stdio' ? 'stdio' : 'http',
|
|
101
|
+
toolSurface: normalizeToolSurface(process.env.HI_MCP_TOOL_SURFACE),
|
|
102
|
+
authMode: normalizeText(process.env.HI_MCP_AUTH_MODE).toLowerCase() === 'oauth' ? 'oauth' : 'local',
|
|
103
|
+
// HI_MCP_OAUTH_RESOURCE accepts a single URL or a comma/whitespace-separated
|
|
104
|
+
// list. First entry is the primary. Old single-value deployments continue
|
|
105
|
+
// to work unchanged (a 1-element array).
|
|
106
|
+
oauthMcpResources: readListEnv('HI_MCP_OAUTH_RESOURCE', []),
|
|
107
|
+
oauthAuthorizationServer: normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER),
|
|
108
|
+
// Backwards-compat: when HI_MCP_OAUTH_ISSUER is not set, fall back to
|
|
109
|
+
// HI_MCP_OAUTH_AUTHORIZATION_SERVER so older single-tenant smoke setups
|
|
110
|
+
// (where AS URL == JWT issuer == same host) keep working.
|
|
111
|
+
oauthIssuer: normalizeText(process.env.HI_MCP_OAUTH_ISSUER)
|
|
112
|
+
|| normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER),
|
|
113
|
+
oauthScopesSupported: readListEnv('HI_MCP_OAUTH_SCOPES', ['hi.read', 'hi.write', 'hi.events']),
|
|
37
114
|
};
|
|
38
115
|
let capabilityCache = null;
|
|
39
116
|
function assertConfig() {
|
|
@@ -43,6 +120,20 @@ function assertConfig() {
|
|
|
43
120
|
if (!Number.isFinite(config.port) || config.port <= 0) {
|
|
44
121
|
throw new Error('invalid_hi_mcp_port');
|
|
45
122
|
}
|
|
123
|
+
if (config.authMode === 'oauth') {
|
|
124
|
+
if (config.transport !== 'http') {
|
|
125
|
+
throw new Error('oauth_auth_mode_requires_http_transport');
|
|
126
|
+
}
|
|
127
|
+
if (config.oauthMcpResources.length === 0) {
|
|
128
|
+
throw new Error('missing_hi_mcp_oauth_resource');
|
|
129
|
+
}
|
|
130
|
+
if (!config.oauthIssuer) {
|
|
131
|
+
throw new Error('missing_hi_mcp_oauth_issuer');
|
|
132
|
+
}
|
|
133
|
+
if (!config.oauthAuthorizationServer) {
|
|
134
|
+
throw new Error('missing_hi_mcp_oauth_authorization_server');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
46
137
|
}
|
|
47
138
|
function jsonText(value) {
|
|
48
139
|
return JSON.stringify(value, null, 2);
|
|
@@ -103,7 +194,7 @@ function controlTools() {
|
|
|
103
194
|
type: 'object',
|
|
104
195
|
properties: {
|
|
105
196
|
display_name: { type: 'string', description: '首次安装且当前 profile 还没有 identity 时使用的人类可读名称。省略时会按 host_kind 使用稳定默认值:OpenClaw 为 `OpenClaw Hi Agent`,generic 为 `Hi Agent`。' },
|
|
106
|
-
host_kind: { type: 'string', description: "
|
|
197
|
+
host_kind: { type: 'string', description: "可选。已知:'openclaw' | 'generic' | 'codex' | 'claude_code' | 'claude_chat' | 'claude_cowork' | 'claude_desktop' | 'chatgpt_app'。默认 generic。仅 'openclaw' 触发本地 receiver / hooks 适配;其他值按远程 / pull 形态走,但仍会保留在 install state 里用于 doctor + telemetry。" },
|
|
107
198
|
agent_kind: { type: 'string', description: '首次 register 时可选的 agent_kind。默认 external。' },
|
|
108
199
|
replace_existing_state: { type: 'boolean', description: '首次 register 且本地已有 state 时,是否允许覆盖本地持久化身份。' },
|
|
109
200
|
migrate_legacy_state: { type: 'boolean', description: '默认 true。若 canonical state 为空,则尝试一次已知 legacy state 迁移。' },
|
|
@@ -128,6 +219,10 @@ function controlTools() {
|
|
|
128
219
|
default_reply_thread_id: { type: 'string', description: '可选:default continuation route 的 thread_id。' },
|
|
129
220
|
subscribe_default_topics: { type: 'boolean', description: '是否自动补齐 Hi 官方默认事件订阅。默认 true。' },
|
|
130
221
|
run_doctor: { type: 'boolean', description: '默认 true。安装完成后自动跑一次 hi_agent_doctor。' },
|
|
222
|
+
metadata: {
|
|
223
|
+
type: 'object',
|
|
224
|
+
description: '首次 register 时透传给 hi-platform 写入 agents.metadata_json 的自定义字段。常见用法:渠道归因 `{"channel_code":"..."}`——用户被某邀请落地页提供的 prompt 里带了 channel_code,必须**原样保留**透传给本工具,不要丢弃也不要修改其值。',
|
|
225
|
+
},
|
|
131
226
|
},
|
|
132
227
|
},
|
|
133
228
|
},
|
|
@@ -154,6 +249,14 @@ function controlTools() {
|
|
|
154
249
|
},
|
|
155
250
|
},
|
|
156
251
|
},
|
|
252
|
+
{
|
|
253
|
+
name: 'hi_agent_state_resync',
|
|
254
|
+
description: '把本地持久化 identity 拉回 remote canonical(/me + /installation 当前真值)。专为 server-side admin consolidate / mergeAgents 之后 client local state 还停在 merge 前快照的场景兜底;只刷 agent_id / display_name / delivery_capabilities,不动 installation_id 和长期凭证(client_id / client_secret 等)。看到 hi_agent_doctor 报 `agent_identity_split` blocker 时调这个工具,不要再调 hi_agent_register(会再造孤儿)。',
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
157
260
|
{
|
|
158
261
|
name: 'hi_agent_register',
|
|
159
262
|
description: '按 Hi 官方 register -> token -> activate 主线创建一个新的 external agent installation,并把长期凭证持久化到本地 state。',
|
|
@@ -195,6 +298,25 @@ function controlTools() {
|
|
|
195
298
|
},
|
|
196
299
|
},
|
|
197
300
|
},
|
|
301
|
+
{
|
|
302
|
+
name: 'hi_agent_claim_export',
|
|
303
|
+
description: '导出一个一次性、短过期的"重挂凭单"(claim token),用于把另一台新设备挂回**当前这个 agent**(同一身份)。要求当前 agent 已绑定手机号(=工作区所有权证明;没绑会 403,请先用 phone_binding 绑定)。返回的 claim_token 像密码一样——谁 redeem 谁就接入这个 agent,只发给你自己的其它设备。典型场景:换电脑 / 重装 / 凭证丢了,不想变成一个新的空 agent、也不想重新绑手机时——在老设备上 export,到新设备上调 hi_agent_claim_redeem,就能继续用同一个 agent(listings / 会话 / 对端回复都在)。',
|
|
304
|
+
inputSchema: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: {},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'hi_agent_claim_redeem',
|
|
311
|
+
description: '在一台**新设备 / 新安装**上消费另一台设备用 hi_agent_claim_export 导出的 claim_token,把当前这台安装重挂到那个 agent——之后你就是同一个 agent(之前的 listings、会话、对端回复都在,可直接接着回复)。一次性使用,用过即作废;过期 / 被吊销 / 已用过都会被拒。',
|
|
312
|
+
inputSchema: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
claim_token: { type: 'string', description: '老设备 hi_agent_claim_export 返回的一次性凭单 secret。' },
|
|
316
|
+
},
|
|
317
|
+
required: ['claim_token'],
|
|
318
|
+
},
|
|
319
|
+
},
|
|
198
320
|
{
|
|
199
321
|
name: 'hi_agent_installation_get',
|
|
200
322
|
description: '读取当前 installation 的正式 persisted state,包括 delivery_capabilities。',
|
|
@@ -326,7 +448,7 @@ function controlTools() {
|
|
|
326
448
|
},
|
|
327
449
|
{
|
|
328
450
|
name: 'hi_agent_events_wait',
|
|
329
|
-
description: '用 claim/poll 等待下一批 durable events,适合没有 live push 的 MCP-first
|
|
451
|
+
description: '用 claim/poll 等待下一批 durable events,适合没有 live push 的 MCP-first 宿主。\n\n**宿主无 push channel 时(典型如 Codex CLI)**:用户结束当前 turn 后没有任何 background process 会把 Hi 事件投递给 LLM —— 对方 agent 的 reply、meeting 确认、listing reaction 都只会静静躺在 Hi outbox 里等下次 claim/wait。这种宿主上发起任何 outreach 后(pairings.contact_target / matching_sessions.contact_match / thread_meetings.start / agent_listings.upsert),都应当主动建议用户开一条 scheduled drain:Codex 用户可以用 thread automation(每 N 分钟唤回当前 thread)或外置 cron + `codex exec "drain my Hi inbox via hi_agent_events_wait then ack"`;其它 MCP-first 宿主同理。如果用户没设这种轮询,对方的回复就会一直被 silent-suppress 在 outbox 里直到用户主动问起,体验上等同于"消息丢了"。',
|
|
330
452
|
inputSchema: {
|
|
331
453
|
type: 'object',
|
|
332
454
|
properties: {
|
|
@@ -394,41 +516,91 @@ async function createBootstrapClients() {
|
|
|
394
516
|
gateway,
|
|
395
517
|
};
|
|
396
518
|
}
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
519
|
+
// Per-subject record of "this process has just quarantined a stale identity
|
|
520
|
+
// belonging to <subject_id>". In OAuth multi-tenant HTTP mode we MUST NOT
|
|
521
|
+
// share a single notice across subjects — A's quarantine notice would
|
|
522
|
+
// otherwise leak A's issuer / previous_agent_id / previous_installation_id
|
|
523
|
+
// out through B's hi_agent_status response. In stdio / single-tenant mode
|
|
524
|
+
// there's exactly one operator and we keep the historic single-notice
|
|
525
|
+
// behaviour (keyed under the bare `config.profile`).
|
|
526
|
+
const quarantineNoticeBySubject = new Map();
|
|
527
|
+
function noticeKeyForCurrentSubject() {
|
|
528
|
+
if (config.authMode !== 'oauth')
|
|
529
|
+
return config.profile;
|
|
530
|
+
const auth = getCurrentRequestAuth();
|
|
531
|
+
return auth?.subjectId ? `subject:${auth.subjectId}` : config.profile;
|
|
532
|
+
}
|
|
533
|
+
// Effective profile for state I/O. In OAuth multi-tenant HTTP mode, each
|
|
534
|
+
// /mcp request carries a verified bearer with a `sub` claim — we derive a
|
|
535
|
+
// per-subject suffix so two OAuth callers never share a state file.
|
|
536
|
+
//
|
|
537
|
+
// Fail-closed: if OAuth mode is active but the request auth is somehow not
|
|
538
|
+
// in scope (e.g. a tool handler escapes the AsyncLocalStorage frame via a
|
|
539
|
+
// stray setTimeout / unawaited promise), we throw rather than silently
|
|
540
|
+
// falling back to `config.profile` (which would write into the shared
|
|
541
|
+
// single-tenant file — exactly the cross-tenant leak this isolation aims
|
|
542
|
+
// to prevent). stdio / local single-tenant mode never has request auth, so
|
|
543
|
+
// it cleanly falls back to `config.profile` and the historic single-file
|
|
544
|
+
// layout.
|
|
545
|
+
function effectiveStateProfile() {
|
|
546
|
+
if (config.authMode !== 'oauth')
|
|
547
|
+
return config.profile;
|
|
548
|
+
const auth = getCurrentRequestAuth();
|
|
549
|
+
if (!auth?.subjectId) {
|
|
550
|
+
throw new Error('oauth_state_io_outside_request_auth_scope');
|
|
551
|
+
}
|
|
552
|
+
return composeSubjectScopedProfile(config.profile, auth.subjectId);
|
|
553
|
+
}
|
|
402
554
|
async function loadPersistedState() {
|
|
555
|
+
const profile = effectiveStateProfile();
|
|
403
556
|
const raw = await readState({
|
|
404
557
|
stateDir: config.stateDir,
|
|
405
|
-
profile
|
|
558
|
+
profile,
|
|
406
559
|
});
|
|
407
560
|
const { state, quarantined } = await quarantineStaleIdentityIfNeeded({
|
|
408
561
|
stateDir: config.stateDir,
|
|
409
|
-
profile
|
|
562
|
+
profile,
|
|
410
563
|
currentPlatformBaseUrl: config.platformBaseUrl,
|
|
411
564
|
state: raw,
|
|
412
565
|
logStderr: (line) => process.stderr.write(`${line}\n`),
|
|
413
566
|
});
|
|
414
|
-
if (quarantined)
|
|
415
|
-
|
|
567
|
+
if (quarantined) {
|
|
568
|
+
quarantineNoticeBySubject.set(noticeKeyForCurrentSubject(), quarantined);
|
|
569
|
+
}
|
|
416
570
|
return state;
|
|
417
571
|
}
|
|
418
572
|
// peek 不消费,让 install / status / doctor 调用期间的所有响应都能 surface 一次同样的
|
|
419
|
-
// quarantine event;mcp 进程退出后 in-memory 通知自然丢掉。
|
|
573
|
+
// quarantine event;mcp 进程退出后 in-memory 通知自然丢掉。In OAuth multi-tenant
|
|
574
|
+
// HTTP mode the notice is scoped to the current request's subject so other
|
|
575
|
+
// subjects never see this subject's quarantine details.
|
|
420
576
|
export function peekQuarantineNotice() {
|
|
421
|
-
return
|
|
577
|
+
return quarantineNoticeBySubject.get(noticeKeyForCurrentSubject()) || null;
|
|
422
578
|
}
|
|
423
579
|
async function persistState(updater) {
|
|
424
580
|
return await updateState({
|
|
425
581
|
stateDir: config.stateDir,
|
|
426
|
-
profile:
|
|
582
|
+
profile: effectiveStateProfile(),
|
|
427
583
|
updater,
|
|
428
584
|
});
|
|
429
585
|
}
|
|
430
586
|
async function createAuthorizedClients() {
|
|
431
587
|
let state = await loadPersistedState();
|
|
588
|
+
// OAuth mode: the inbound /mcp request already carries a verified bearer
|
|
589
|
+
// bound to a Codex / Claude Code installation. Replay it verbatim to
|
|
590
|
+
// downstream Hi services and skip the disk identity entirely — there is
|
|
591
|
+
// no "current installation on this process" when running multi-tenant.
|
|
592
|
+
const requestAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
|
|
593
|
+
if (requestAuth) {
|
|
594
|
+
const clients = await createHiAgentClients({
|
|
595
|
+
platformBaseUrl: config.platformBaseUrl,
|
|
596
|
+
token: requestAuth.bearer,
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
state,
|
|
600
|
+
accessToken: requestAuth.bearer,
|
|
601
|
+
...clients,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
432
604
|
if (!state.identity)
|
|
433
605
|
throw new Error('missing_agent_identity');
|
|
434
606
|
const token = await exchangeHiAgentClientCredentialsToken({
|
|
@@ -459,11 +631,25 @@ async function createAuthorizedClients() {
|
|
|
459
631
|
function resolveCurrentStateFile() {
|
|
460
632
|
return resolveStateFile({
|
|
461
633
|
stateDir: config.stateDir,
|
|
462
|
-
profile:
|
|
634
|
+
profile: effectiveStateProfile(),
|
|
463
635
|
});
|
|
464
636
|
}
|
|
465
637
|
function normalizeHostKind(raw) {
|
|
466
|
-
|
|
638
|
+
const value = normalizeText(raw).toLowerCase().replace(/-/g, '_');
|
|
639
|
+
if (!value)
|
|
640
|
+
return 'generic';
|
|
641
|
+
// Aliases for host strings users may type without underscores.
|
|
642
|
+
if (value === 'claudecode')
|
|
643
|
+
return 'claude_code';
|
|
644
|
+
if (value === 'claudechat' || value === 'claude')
|
|
645
|
+
return 'claude_chat';
|
|
646
|
+
if (value === 'claudedesktop')
|
|
647
|
+
return 'claude_desktop';
|
|
648
|
+
if (value === 'claudecowork' || value === 'cowork')
|
|
649
|
+
return 'claude_cowork';
|
|
650
|
+
if (value === 'chatgpt' || value === 'chatgptapp')
|
|
651
|
+
return 'chatgpt_app';
|
|
652
|
+
return KNOWN_HOST_KINDS.has(value) ? value : 'generic';
|
|
467
653
|
}
|
|
468
654
|
function normalizeReceiverTransport(raw) {
|
|
469
655
|
return normalizeText(raw).toLowerCase() === 'stream' ? 'stream' : 'claim';
|
|
@@ -489,6 +675,17 @@ function buildInstallRuntimeState(current, patch) {
|
|
|
489
675
|
}
|
|
490
676
|
async function maybeMigrateLegacyState() {
|
|
491
677
|
const targetStateFile = resolveCurrentStateFile();
|
|
678
|
+
// Legacy single-tenant state migration is unsafe in OAuth multi-tenant mode:
|
|
679
|
+
// the legacy candidate files (e.g. `~/.openclaw/hi/default.json`) are
|
|
680
|
+
// un-scoped per subject, so copying one into a new OAuth subject's
|
|
681
|
+
// subject-scoped state file would re-introduce the cross-tenant identity
|
|
682
|
+
// leak we just closed in commit ab8c41d (every fresh OAuth subject on first
|
|
683
|
+
// install would inherit whatever identity the legacy single-tenant install
|
|
684
|
+
// had left on disk). Stdio / local single-tenant mode keeps the historic
|
|
685
|
+
// migration behaviour because there it's exactly the operator's own state.
|
|
686
|
+
if (config.authMode === 'oauth') {
|
|
687
|
+
return { migrated: false, from: null, to: targetStateFile };
|
|
688
|
+
}
|
|
492
689
|
try {
|
|
493
690
|
await fs.access(targetStateFile);
|
|
494
691
|
return { migrated: false, from: null, to: targetStateFile };
|
|
@@ -654,7 +851,7 @@ function buildDefaultReplyRoute(args, options = {}) {
|
|
|
654
851
|
requireOpenClawSessionKey: options.requireOpenClawSessionKey,
|
|
655
852
|
});
|
|
656
853
|
const deliveryContext = resolveInstallDefaultReplyDeliveryContext({
|
|
657
|
-
hostKind: options.hostKind
|
|
854
|
+
hostKind: options.hostKind || 'generic',
|
|
658
855
|
hasSessionKey: !!sessionKey,
|
|
659
856
|
defaultReplyChannel: args.default_reply_channel,
|
|
660
857
|
defaultReplyTo: args.default_reply_to,
|
|
@@ -829,6 +1026,14 @@ async function startDetachedReceiver(args) {
|
|
|
829
1026
|
function buildDoctorSummary(args) {
|
|
830
1027
|
const installState = stateInstallSnapshot(args.state.runtime);
|
|
831
1028
|
const remoteInstallation = args.remote?.installation;
|
|
1029
|
+
const remoteMe = args.remote?.me;
|
|
1030
|
+
// OAuth 多租户模式下没有本地 state.identity;connected/activated 完全靠 remote probe
|
|
1031
|
+
// 推。OpenClaw 路径反过来——本地 identity 才是 truth,没远端响应也能算 connected。
|
|
1032
|
+
const remoteConnected = !!(normalizeText(remoteMe?.agent?.agent_id)
|
|
1033
|
+
&& normalizeText(remoteMe?.agent?.status || 'active').toLowerCase() !== 'disabled'
|
|
1034
|
+
&& normalizeText(remoteInstallation?.installation?.status || 'active').toLowerCase() !== 'disabled');
|
|
1035
|
+
const connected = !!args.state.identity || remoteConnected;
|
|
1036
|
+
const activated = !!(remoteInstallation?.installation?.activated_at || args.state.identity?.activated_at);
|
|
832
1037
|
const deliveryDeclaration = remoteInstallation?.installation?.delivery_capabilities
|
|
833
1038
|
|| args.state.identity?.delivery_capabilities
|
|
834
1039
|
|| null;
|
|
@@ -851,8 +1056,8 @@ function buildDoctorSummary(args) {
|
|
|
851
1056
|
state_file: resolveCurrentStateFile(),
|
|
852
1057
|
canonical_openclaw_state_dir: resolveCanonicalOpenClawStateDir(config.profile),
|
|
853
1058
|
quarantined_stale_identity: peekQuarantineNotice(),
|
|
854
|
-
connected
|
|
855
|
-
activated
|
|
1059
|
+
connected,
|
|
1060
|
+
activated,
|
|
856
1061
|
event_path: deliveryDeclaration?.preferred || (localReceiverEnabled ? 'local_receiver' : 'claim_ack'),
|
|
857
1062
|
receiver_config_path: installState.receiver_config_path,
|
|
858
1063
|
receiver_running: isProcessAlive(installState.receiver_pid),
|
|
@@ -940,6 +1145,22 @@ async function diagnoseOpenClawHookBasePathMismatch(args) {
|
|
|
940
1145
|
};
|
|
941
1146
|
}
|
|
942
1147
|
async function handleRegister(args) {
|
|
1148
|
+
// 2026-05:OAuth 多租户模式(Codex / Claude Code 走 /mcp)下,bearer 已经在 hi-auth
|
|
1149
|
+
// 那一层通过 ensureRemoteOAuthAgentConnection 自动 provision 了 agent + installation
|
|
1150
|
+
// (见 hi-agent-gateway agentPlatform.ts:ensureRemoteOAuthAgentConnection)。再走
|
|
1151
|
+
// gateway.register 会 mint 一个新 installation_id 当 auth_principal,造出一条
|
|
1152
|
+
// 跟当前 bearer 不挂钩的孤儿 agent ——本地 state 写了它但 bearer 永远 resolve 回旧 agent。
|
|
1153
|
+
// 用户感知到的就是"local identity 跟 /me 不一致 → 我的数据没了"。
|
|
1154
|
+
//
|
|
1155
|
+
// OAuth 模式正确做身份切换的路径:admin 调 /internal/agents/:source/consolidate-into
|
|
1156
|
+
// 让 installation 原子改绑到 target;客户端跑 hi_agent_state_resync 让 local state
|
|
1157
|
+
// 跟上 remote。绝对不要在 OAuth 路径下再走 register。
|
|
1158
|
+
if (config.authMode === 'oauth' && getCurrentRequestAuth()) {
|
|
1159
|
+
return fail('hi_agent_register_not_allowed_in_oauth_mode', {
|
|
1160
|
+
hint: 'OAuth bearer 已经通过 ensureRemoteOAuthAgentConnection 自动 provision 了 agent;再 register 会造孤儿(local agent_id 跟 bearer 解析出的 agent_id 永远不一致)。需要切换身份请 admin 调 /internal/agents/:source/consolidate-into;客户端本地 state 出 drift 请用 hi_agent_state_resync。',
|
|
1161
|
+
profile: config.profile,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
943
1164
|
const current = await loadPersistedState();
|
|
944
1165
|
if (current.identity && args.replace_existing_state !== true) {
|
|
945
1166
|
return fail('agent_state_already_exists', {
|
|
@@ -995,6 +1216,108 @@ async function handleRegister(args) {
|
|
|
995
1216
|
persisted_profile: config.profile,
|
|
996
1217
|
});
|
|
997
1218
|
}
|
|
1219
|
+
// 2026-05:在 server-side agent_installations.agent_id 被 admin consolidate / mergeAgents
|
|
1220
|
+
// 改绑之后,把本地 state.identity 拉回 remote canonical。bearer 不动、installation_id 不动、
|
|
1221
|
+
// 长期凭证(client_id / client_secret 等)不动——只刷三个被 merge 改过的字段:agent_id、
|
|
1222
|
+
// display_name、delivery_capabilities。专门给 hi_agent_doctor 报 agent_identity_split blocker
|
|
1223
|
+
// 之后兜底用,不需要服务端配合的轻量 client-side fix。
|
|
1224
|
+
//
|
|
1225
|
+
// 跟 hi_agent_connect 的区别:connect 是 "把当前 installation 绑到另一个 agent",会调
|
|
1226
|
+
// gateway.connect 走 mutation 路径——不该在已经 merge 完的 installation 上重复发起。
|
|
1227
|
+
// 这里只读 /me + /installation,纯 client-side 文件 patch,不进 hi-platform 写路径,
|
|
1228
|
+
// 也不会跟正在跑的 admin merge 冲突。
|
|
1229
|
+
//
|
|
1230
|
+
// OAuth 模式下 state.identity 永远是 null(bearer 决定身份),调用时直接 no-op 报回当前
|
|
1231
|
+
// remote view——不应该阻断,让 LLM 在 OAuth 路径下也能用它做"确认我现在到底是谁"探查。
|
|
1232
|
+
async function handleStateResync(_args) {
|
|
1233
|
+
const { gateway } = await createAuthorizedClients();
|
|
1234
|
+
const [me, installation] = await Promise.all([
|
|
1235
|
+
gateway.me(),
|
|
1236
|
+
gateway.getInstallation(),
|
|
1237
|
+
]);
|
|
1238
|
+
const remoteAgentId = normalizeText(me?.agent?.agent_id) || null;
|
|
1239
|
+
const remoteInstallationId = normalizeText(installation.installation?.installation_id) || null;
|
|
1240
|
+
const remoteAgentDisplayName = normalizeText(me?.agent?.display_name) || null;
|
|
1241
|
+
const remoteAgentKind = normalizeText(me?.agent?.agent_kind) || null;
|
|
1242
|
+
const remoteDeliveryCapabilities = installation.installation?.delivery_capabilities || null;
|
|
1243
|
+
const remoteActivatedAt = normalizeText(installation.installation?.activated_at) || null;
|
|
1244
|
+
if (!remoteAgentId || !remoteInstallationId) {
|
|
1245
|
+
return fail('state_resync_remote_unreadable', {
|
|
1246
|
+
hint: 'gateway.me / getInstallation 没回出 canonical IDs;可能是 bearer 还没 provision agent,跑 hi_agent_install 先。',
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
// OAuth 多租户模式(Codex / Claude Code 走 /mcp):state.identity 故意是 null,
|
|
1250
|
+
// 每条请求的真身份由 bearer 决定。即使 disk 上有残留 identity(不该有但理论可能),
|
|
1251
|
+
// 也不要 patch——把 disk 改成"当前 bearer 解析出的 agent"在多租户下是错的(下一条
|
|
1252
|
+
// 请求可能是另一个 subject)。直接报当前 remote view 让 caller 知道现在 bearer
|
|
1253
|
+
// 落在哪个 agent。
|
|
1254
|
+
if (config.authMode === 'oauth') {
|
|
1255
|
+
return ok({
|
|
1256
|
+
ok: true,
|
|
1257
|
+
patched: false,
|
|
1258
|
+
reason: 'oauth_multitenant_no_disk_identity',
|
|
1259
|
+
remote: {
|
|
1260
|
+
agent_id: remoteAgentId,
|
|
1261
|
+
installation_id: remoteInstallationId,
|
|
1262
|
+
display_name: remoteAgentDisplayName,
|
|
1263
|
+
agent_kind: remoteAgentKind,
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
const before = await loadPersistedState();
|
|
1268
|
+
const beforeIdentity = before.identity;
|
|
1269
|
+
const beforeAgentId = beforeIdentity?.agent_id || null;
|
|
1270
|
+
const beforeInstallationId = beforeIdentity?.installation_id || null;
|
|
1271
|
+
// 非 OAuth 模式下到这里 state.identity 不应该是 null——createAuthorizedClients 在
|
|
1272
|
+
// 没有 disk identity 时已经 throw missing_agent_identity 提前结束。保险起见还是检查一遍。
|
|
1273
|
+
if (!beforeIdentity) {
|
|
1274
|
+
return fail('state_resync_no_local_identity', {
|
|
1275
|
+
hint: '非 OAuth 模式下 local state 还没 identity,先跑 hi_agent_install。',
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
const agentIdDrift = beforeAgentId !== remoteAgentId;
|
|
1279
|
+
const installationIdDrift = beforeInstallationId !== remoteInstallationId;
|
|
1280
|
+
if (!agentIdDrift && !installationIdDrift) {
|
|
1281
|
+
return ok({
|
|
1282
|
+
ok: true,
|
|
1283
|
+
patched: false,
|
|
1284
|
+
reason: 'no_drift',
|
|
1285
|
+
identity: { agent_id: beforeAgentId, installation_id: beforeInstallationId },
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
// installation_id drift 在 client_credentials 模式下意味着客户端凭证完全错位——
|
|
1289
|
+
// client_id / client_secret 还认旧 installation 的 token,但 server 把 installation
|
|
1290
|
+
// 重新绑给了另一个 (auth_principal, auth_issuer) tuple,那 bearer 自己就拉不到 /me。
|
|
1291
|
+
// 这种情况 resync 救不了,得走 hi_agent_install 重建凭证。
|
|
1292
|
+
if (installationIdDrift) {
|
|
1293
|
+
return fail('state_resync_installation_id_drift_unrecoverable', {
|
|
1294
|
+
local_installation_id: beforeInstallationId,
|
|
1295
|
+
remote_installation_id: remoteInstallationId,
|
|
1296
|
+
hint: '本地 installation_id 跟 /me 拿到的 installation 不一致——这说明 client_credentials 凭证已经不再认得当前 installation。resync 只能修 agent_id drift(merge 场景)。需要重新走 hi_agent_install。',
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
await persistState((current) => ({
|
|
1300
|
+
...current,
|
|
1301
|
+
identity: current.identity
|
|
1302
|
+
? {
|
|
1303
|
+
...current.identity,
|
|
1304
|
+
agent_id: remoteAgentId,
|
|
1305
|
+
display_name: remoteAgentDisplayName || current.identity.display_name,
|
|
1306
|
+
agent_kind: remoteAgentKind || current.identity.agent_kind,
|
|
1307
|
+
activated_at: remoteActivatedAt || current.identity.activated_at,
|
|
1308
|
+
delivery_capabilities: remoteDeliveryCapabilities || current.identity.delivery_capabilities,
|
|
1309
|
+
}
|
|
1310
|
+
: current.identity,
|
|
1311
|
+
runtime: { ...current.runtime, updated_at: new Date().toISOString() },
|
|
1312
|
+
}));
|
|
1313
|
+
return ok({
|
|
1314
|
+
ok: true,
|
|
1315
|
+
patched: true,
|
|
1316
|
+
reason: 'agent_id_resynced_from_remote',
|
|
1317
|
+
before: { agent_id: beforeAgentId, installation_id: beforeInstallationId },
|
|
1318
|
+
after: { agent_id: remoteAgentId, installation_id: remoteInstallationId },
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
998
1321
|
async function handleConnect(args) {
|
|
999
1322
|
const { gateway, state } = await createAuthorizedClients();
|
|
1000
1323
|
const connected = await gateway.connect({
|
|
@@ -1056,8 +1379,117 @@ async function handleActivate(args) {
|
|
|
1056
1379
|
contract: activated.contract,
|
|
1057
1380
|
});
|
|
1058
1381
|
}
|
|
1382
|
+
// claim-token re-attach (2026-06):登录后"重挂凭单"。export 在已绑手机的 agent 上签发一个一次性
|
|
1383
|
+
// 短过期凭单;redeem 在另一台新设备上消费它,把这台设备挂回同一个 agent。直接打 gateway 的
|
|
1384
|
+
// /v1/agents/claim/* (与 connect/activate 同一 registry base),复用当前请求/凭证换来的 bearer。
|
|
1385
|
+
function claimGatewayBaseUrl(clients) {
|
|
1386
|
+
const wk = clients.wellKnown;
|
|
1387
|
+
return (wk?.platform?.registry_base_url || wk?.registry_base_url || config.platformBaseUrl);
|
|
1388
|
+
}
|
|
1389
|
+
async function handleClaimExport(_args) {
|
|
1390
|
+
const clients = await createAuthorizedClients();
|
|
1391
|
+
const res = await fetch(`${claimGatewayBaseUrl(clients)}/v1/agents/claim/export`, {
|
|
1392
|
+
method: 'POST',
|
|
1393
|
+
headers: { authorization: `Bearer ${clients.accessToken}`, 'content-type': 'application/json' },
|
|
1394
|
+
body: '{}',
|
|
1395
|
+
});
|
|
1396
|
+
const data = (await res.json().catch(() => ({})));
|
|
1397
|
+
if (!res.ok)
|
|
1398
|
+
return fail(String(data?.error || 'claim_export_failed'), data);
|
|
1399
|
+
return ok(data);
|
|
1400
|
+
}
|
|
1401
|
+
async function handleClaimRedeem(args) {
|
|
1402
|
+
const claimToken = normalizeText(args.claim_token);
|
|
1403
|
+
if (!claimToken)
|
|
1404
|
+
return fail('missing_claim_token');
|
|
1405
|
+
const clients = await createAuthorizedClients();
|
|
1406
|
+
const res = await fetch(`${claimGatewayBaseUrl(clients)}/v1/agents/claim/redeem`, {
|
|
1407
|
+
method: 'POST',
|
|
1408
|
+
headers: { authorization: `Bearer ${clients.accessToken}`, 'content-type': 'application/json' },
|
|
1409
|
+
body: JSON.stringify({ claim_token: claimToken }),
|
|
1410
|
+
});
|
|
1411
|
+
const data = (await res.json().catch(() => ({})));
|
|
1412
|
+
if (!res.ok)
|
|
1413
|
+
return fail(String(data?.error || 'claim_redeem_failed'), data);
|
|
1414
|
+
// identity 已在服务端重挂到 data.agent_id;本地 client_credentials 不变,下次调用按 principal
|
|
1415
|
+
// 自然解析到新 agent,无需改本地 state。
|
|
1416
|
+
return ok(data);
|
|
1417
|
+
}
|
|
1059
1418
|
async function handleStatus(args) {
|
|
1060
1419
|
const state = await loadPersistedState();
|
|
1420
|
+
const requestAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
|
|
1421
|
+
if (requestAuth) {
|
|
1422
|
+
const auth = {
|
|
1423
|
+
subject: requestAuth.subjectId,
|
|
1424
|
+
issuer: normalizeText(requestAuth.claims?.iss),
|
|
1425
|
+
audience: requestAuth.claims?.aud ?? null,
|
|
1426
|
+
client_id: normalizeText(requestAuth.claims?.client_id) || null,
|
|
1427
|
+
scope: normalizeText(requestAuth.claims?.scope) || null,
|
|
1428
|
+
};
|
|
1429
|
+
try {
|
|
1430
|
+
const { gateway } = await createAuthorizedClients();
|
|
1431
|
+
const [me, installation, endpoints, subscriptions] = await Promise.all([
|
|
1432
|
+
gateway.me(),
|
|
1433
|
+
gateway.getInstallation(),
|
|
1434
|
+
gateway.listEndpoints(),
|
|
1435
|
+
gateway.listSubscriptions(),
|
|
1436
|
+
]);
|
|
1437
|
+
const remoteAgent = isPlainObject(me?.agent) ? me.agent : null;
|
|
1438
|
+
const remoteInstallation = isPlainObject(installation?.installation)
|
|
1439
|
+
? installation.installation
|
|
1440
|
+
: null;
|
|
1441
|
+
const connected = normalizeText(remoteAgent?.agent_id) !== ''
|
|
1442
|
+
&& normalizeText(remoteAgent?.status || 'active') !== 'disabled'
|
|
1443
|
+
&& normalizeText(remoteInstallation?.installation_id) !== ''
|
|
1444
|
+
&& normalizeText(remoteInstallation?.status || 'active') !== 'disabled';
|
|
1445
|
+
const activated = connected && (normalizeText(remoteInstallation?.activated_at) !== ''
|
|
1446
|
+
|| normalizeText(remoteInstallation?.status) === 'active');
|
|
1447
|
+
return ok({
|
|
1448
|
+
ok: true,
|
|
1449
|
+
mode: 'remote_oauth',
|
|
1450
|
+
profile: config.profile,
|
|
1451
|
+
state_dir: config.stateDir,
|
|
1452
|
+
state_file: resolveCurrentStateFile(),
|
|
1453
|
+
auth,
|
|
1454
|
+
quarantined_stale_identity: peekQuarantineNotice(),
|
|
1455
|
+
summary: {
|
|
1456
|
+
connected,
|
|
1457
|
+
activated,
|
|
1458
|
+
receiver_config_path: null,
|
|
1459
|
+
receiver_running: false,
|
|
1460
|
+
event_path: remoteInstallation?.delivery_capabilities?.preferred || null,
|
|
1461
|
+
},
|
|
1462
|
+
state,
|
|
1463
|
+
remote: {
|
|
1464
|
+
me,
|
|
1465
|
+
installation,
|
|
1466
|
+
endpoints,
|
|
1467
|
+
subscriptions,
|
|
1468
|
+
},
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1473
|
+
return ok({
|
|
1474
|
+
ok: true,
|
|
1475
|
+
mode: 'remote_oauth',
|
|
1476
|
+
profile: config.profile,
|
|
1477
|
+
state_dir: config.stateDir,
|
|
1478
|
+
state_file: resolveCurrentStateFile(),
|
|
1479
|
+
auth,
|
|
1480
|
+
quarantined_stale_identity: peekQuarantineNotice(),
|
|
1481
|
+
summary: {
|
|
1482
|
+
connected: false,
|
|
1483
|
+
activated: false,
|
|
1484
|
+
receiver_config_path: null,
|
|
1485
|
+
receiver_running: false,
|
|
1486
|
+
},
|
|
1487
|
+
reason: 'remote_oauth_installation_unavailable',
|
|
1488
|
+
detail: message || 'unknown_error',
|
|
1489
|
+
state,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1061
1493
|
if (args.include_remote !== true || !state.identity) {
|
|
1062
1494
|
return ok({
|
|
1063
1495
|
ok: true,
|
|
@@ -1119,7 +1551,13 @@ async function handleDoctor(args) {
|
|
|
1119
1551
|
session_key: null,
|
|
1120
1552
|
};
|
|
1121
1553
|
let openClawStateDirValidation = null;
|
|
1122
|
-
|
|
1554
|
+
// OAuth 多租户模式(Codex / Claude Code 走 /mcp)下,state.identity 故意是 null —— 真
|
|
1555
|
+
// 身份由请求 bearer 决定,没有"per-process 单一 identity"概念(见 createAuthorizedClients
|
|
1556
|
+
// 注释)。doctor 在这种模式下 fallback 走 remote probe 判断 connected/activated,不再把
|
|
1557
|
+
// state.identity 缺失算 blocker,否则每次 hi_agent_install 末尾跑 doctor 都会拿到
|
|
1558
|
+
// `missing_agent_identity` 的假阳性,把刚成功的 OAuth install 标成 fail。
|
|
1559
|
+
const oauthDoctorAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
|
|
1560
|
+
if (!state.identity && !oauthDoctorAuth) {
|
|
1123
1561
|
blockers.push('missing_agent_identity');
|
|
1124
1562
|
}
|
|
1125
1563
|
if (installState.host_kind === 'openclaw') {
|
|
@@ -1136,7 +1574,9 @@ async function handleDoctor(args) {
|
|
|
1136
1574
|
}
|
|
1137
1575
|
}
|
|
1138
1576
|
}
|
|
1139
|
-
|
|
1577
|
+
// OAuth 模式下 state.identity 是 null 也照样有真身份(bearer 在 request 上),所以
|
|
1578
|
+
// 这里的 gate 用 "有 oauth bearer 或者有本地 identity" 表示"能拿到远端".
|
|
1579
|
+
if (includeRemote && (state.identity || oauthDoctorAuth)) {
|
|
1140
1580
|
try {
|
|
1141
1581
|
const { gateway } = await createAuthorizedClients();
|
|
1142
1582
|
const [me, installation, endpoints, subscriptions] = await Promise.all([
|
|
@@ -1151,6 +1591,23 @@ async function handleDoctor(args) {
|
|
|
1151
1591
|
endpoints: endpoints.endpoints,
|
|
1152
1592
|
subscriptions: subscriptions.subscriptions,
|
|
1153
1593
|
};
|
|
1594
|
+
// 2026-05:身份分叉检测。server-side admin consolidate / agentMerge 之后,
|
|
1595
|
+
// agent_installations.agent_id 会被改成 target;但本地 state.identity.agent_id
|
|
1596
|
+
// 还是 merge 前快照——客户端没机制知道自己被合并掉了。表现是 LLM 看到 "我以为
|
|
1597
|
+
// 是 ag_X 但 /me 说我是 ag_Y" 然后开始反复 reset / re-login / 再造孤儿。
|
|
1598
|
+
//
|
|
1599
|
+
// 这里把三个 agent_id 都拉出来对比,任何一对不一致就直接 blocker。提示客户端
|
|
1600
|
+
// 调 hi_agent_state_resync 把本地刷成 remote canonical(不是新 register,那会
|
|
1601
|
+
// 再造一个孤儿)。OAuth 模式下 state.identity 永远是 null,这条 blocker 自动跳过。
|
|
1602
|
+
const localAgentId = normalizeText(state.identity?.agent_id) || null;
|
|
1603
|
+
const remoteMeAgentId = normalizeText(me?.agent?.agent_id) || null;
|
|
1604
|
+
const remoteInstallationAgentId = normalizeText(installation.installation?.agent_id) || null;
|
|
1605
|
+
if (localAgentId && remoteMeAgentId && localAgentId !== remoteMeAgentId) {
|
|
1606
|
+
blockers.push(`agent_identity_split:local=${localAgentId},remote_me=${remoteMeAgentId},remote_installation=${remoteInstallationAgentId || 'unknown'}`);
|
|
1607
|
+
}
|
|
1608
|
+
else if (remoteMeAgentId && remoteInstallationAgentId && remoteMeAgentId !== remoteInstallationAgentId) {
|
|
1609
|
+
warnings.push(`remote_agent_id_mismatch:me=${remoteMeAgentId},installation=${remoteInstallationAgentId}`);
|
|
1610
|
+
}
|
|
1154
1611
|
const declaration = installation.installation.delivery_capabilities || null;
|
|
1155
1612
|
const defaultReplyRoute = isPlainObject(declaration?.default_reply_route)
|
|
1156
1613
|
? declaration.default_reply_route
|
|
@@ -1268,8 +1725,27 @@ async function handleInstall(args) {
|
|
|
1268
1725
|
? await maybeMigrateLegacyState()
|
|
1269
1726
|
: { migrated: false, from: null, to: resolveCurrentStateFile() };
|
|
1270
1727
|
let state = await loadPersistedState();
|
|
1728
|
+
// OAuth 多租户模式(Codex / Claude Code 远程 MCP)下,agent 在 `codex mcp login hi`
|
|
1729
|
+
// 这一步就已经被 ensureRemoteOAuthAgentConnection 建好 + 激活了——/mcp 请求带过来的
|
|
1730
|
+
// bearer 已经绑到一条真实存在的 active agent。再走 handleRegister 会在 hi-platform
|
|
1731
|
+
// 多开一条 agent 记录(partial install / activated_at=null),admin panel 看到的就是
|
|
1732
|
+
// "Hi OAuth agent xxx + Codex Hi Agent xxx" 两条;同时邀请归因(channel_code)会被
|
|
1733
|
+
// 写到那条 ghost 上,真正在用的 OAuth agent 完全没有 inviter 归属。
|
|
1734
|
+
//
|
|
1735
|
+
// 修复:OAuth 模式下跳过 handleRegister / handleActivate,把 metadata(含
|
|
1736
|
+
// channel_code)顺到下面那次 gateway.updateInstallation 调用里。gateway
|
|
1737
|
+
// 的 updateAgentInstallation 会把 installation metadata 里的 channel_code
|
|
1738
|
+
// 提升到 agents.metadata_json,admin 现有归因 query 不动也能拿到。
|
|
1739
|
+
const oauthInstallAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
|
|
1740
|
+
// Snapshot the identity BEFORE handleRegister/handleActivate run so that if
|
|
1741
|
+
// the trailing gateway.updateInstallation call fails, we can roll back to
|
|
1742
|
+
// the actual pre-install baseline. Capturing it any later (e.g. just before
|
|
1743
|
+
// updateInstallation) is a no-op rollback on fresh installs — the snapshot
|
|
1744
|
+
// would be the already-persisted just-registered identity, and the retry
|
|
1745
|
+
// would short-circuit register and loop on the same updateInstallation 404.
|
|
1746
|
+
const preInstallIdentitySnapshot = state.identity;
|
|
1271
1747
|
let registerPayload = null;
|
|
1272
|
-
if (!state.identity) {
|
|
1748
|
+
if (!oauthInstallAuth && !state.identity) {
|
|
1273
1749
|
const displayName = resolveInstallDisplayName({
|
|
1274
1750
|
explicitDisplayName: args.display_name,
|
|
1275
1751
|
hostKind,
|
|
@@ -1278,6 +1754,10 @@ async function handleInstall(args) {
|
|
|
1278
1754
|
display_name: displayName,
|
|
1279
1755
|
agent_kind: normalizeText(args.agent_kind) || undefined,
|
|
1280
1756
|
replace_existing_state: args.replace_existing_state === true,
|
|
1757
|
+
// metadata 必须透传到 register:渠道归因(如 channel_code)走这条路径
|
|
1758
|
+
// 落进 hi-platform 的 agents.metadata_json,被 admin panel 反查邀请人。
|
|
1759
|
+
// 历史 bug:之前这里漏了 metadata,导致 hi_agent_install 的入参直接被吞。
|
|
1760
|
+
metadata: isPlainObject(args.metadata) ? args.metadata : undefined,
|
|
1281
1761
|
});
|
|
1282
1762
|
if (registerResult.isError)
|
|
1283
1763
|
return registerResult;
|
|
@@ -1286,11 +1766,57 @@ async function handleInstall(args) {
|
|
|
1286
1766
|
: null;
|
|
1287
1767
|
state = await loadPersistedState();
|
|
1288
1768
|
}
|
|
1769
|
+
// Helper: roll the on-disk identity back to whatever we held BEFORE this
|
|
1770
|
+
// hi_agent_install turn touched anything. Used by any failure between
|
|
1771
|
+
// handleRegister/handleActivate/updateInstallation and a successful end —
|
|
1772
|
+
// each of those steps can throw if the gateway 404s (the
|
|
1773
|
+
// sub-vs-installation_subject contract mismatch in OAuth multi-tenant mode
|
|
1774
|
+
// means the just-registered installation isn't visible to the bearer that
|
|
1775
|
+
// tries to mutate it). On a fresh install preInstallIdentitySnapshot is
|
|
1776
|
+
// null, so rollback clears identity and the next install retry starts
|
|
1777
|
+
// cleanly from register. On a re-install we restore the prior identity.
|
|
1778
|
+
async function rollbackInstallIdentity() {
|
|
1779
|
+
try {
|
|
1780
|
+
await persistState((current) => ({
|
|
1781
|
+
...current,
|
|
1782
|
+
identity: preInstallIdentitySnapshot,
|
|
1783
|
+
runtime: { ...current.runtime, updated_at: new Date().toISOString() },
|
|
1784
|
+
}));
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
catch (rollbackError) {
|
|
1788
|
+
process.stderr.write(`[hi-mcp] install rollback failed: ${String(rollbackError)}\n`);
|
|
1789
|
+
return false;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
function buildInstallStepFailure(stepName, errorMessage, rolledBack) {
|
|
1793
|
+
return fail('install_step_failed', {
|
|
1794
|
+
step: stepName,
|
|
1795
|
+
cause: errorMessage,
|
|
1796
|
+
rolled_back_local_identity: rolledBack,
|
|
1797
|
+
hint: `${stepName} failed against the gateway; local identity has been ${rolledBack ? 'restored to its pre-install baseline' : 'left in an inconsistent state — clear with hi_agent_reset before retrying'}. Any gateway-side agent / installation rows created earlier this turn become orphans (tracked as a TODO in the multi-tenant install path).`,
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1289
1800
|
let activatePayload = null;
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1801
|
+
// OAuth 模式同样跳过 activate —— OAuth agent 在 token 兑换那一刻就是 active 的,
|
|
1802
|
+
// 再走一遍 /v1/agents/activate 既无意义也会拿不到 state.identity 一路 throw。
|
|
1803
|
+
if (!oauthInstallAuth && state.identity && !state.identity.activated_at) {
|
|
1804
|
+
let activateResult;
|
|
1805
|
+
try {
|
|
1806
|
+
activateResult = await handleActivate({});
|
|
1807
|
+
}
|
|
1808
|
+
catch (error) {
|
|
1809
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1810
|
+
const rolledBack = await rollbackInstallIdentity();
|
|
1811
|
+
return buildInstallStepFailure('activate', message, rolledBack);
|
|
1812
|
+
}
|
|
1813
|
+
if (activateResult.isError) {
|
|
1814
|
+
const rolledBack = await rollbackInstallIdentity();
|
|
1815
|
+
const innerErr = isPlainObject(activateResult.structuredContent)
|
|
1816
|
+
? activateResult.structuredContent.error
|
|
1817
|
+
: undefined;
|
|
1818
|
+
return buildInstallStepFailure('activate', String(innerErr || 'activate_failed'), rolledBack);
|
|
1819
|
+
}
|
|
1294
1820
|
activatePayload = isPlainObject(activateResult.structuredContent)
|
|
1295
1821
|
? activateResult.structuredContent
|
|
1296
1822
|
: null;
|
|
@@ -1321,9 +1847,46 @@ async function handleInstall(args) {
|
|
|
1321
1847
|
routeMissingPolicy,
|
|
1322
1848
|
defaultReplyRoute,
|
|
1323
1849
|
});
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1850
|
+
let mergedInstallMetadata = null;
|
|
1851
|
+
if (oauthInstallAuth && isPlainObject(args.metadata) && Object.keys(args.metadata).length > 0) {
|
|
1852
|
+
// gateway 的 updateAgentInstallation 把 metadata 当作"整字段 patch"——给了就替换、
|
|
1853
|
+
// 不给就保留——见 mergeInstallationMetadata。OAuth installation 上本来就有
|
|
1854
|
+
// {provisioned_by, oauth_client_id, oauth_scope, oauth_audience} 这些 bookkeeping
|
|
1855
|
+
// 字段(ensureRemoteOAuthAgentConnection 写的),直接 PUT {channel_code: X} 会把它们
|
|
1856
|
+
// 全冲掉。所以这里先拉一次 installation 把现有 metadata 取出来,再浅合并写回。
|
|
1857
|
+
try {
|
|
1858
|
+
const existingInstallation = await gateway.getInstallation();
|
|
1859
|
+
const existing = isPlainObject(existingInstallation?.installation?.metadata)
|
|
1860
|
+
? existingInstallation.installation.metadata
|
|
1861
|
+
: {};
|
|
1862
|
+
mergedInstallMetadata = { ...existing, ...args.metadata };
|
|
1863
|
+
}
|
|
1864
|
+
catch (error) {
|
|
1865
|
+
// 拉不到现有 installation 不应该阻断 install——退化成只写 caller 传的 metadata,
|
|
1866
|
+
// 最坏情况只是 oauth bookkeeping 字段被清空,gateway 仍然能从 agent_installations 行
|
|
1867
|
+
// 重新推导身份。channel_code 仍然落到 installation 上,归因 query 不受影响。
|
|
1868
|
+
process.stderr.write(`[hi-mcp] failed to fetch existing installation metadata before update: ${String(error)}\n`);
|
|
1869
|
+
mergedInstallMetadata = { ...args.metadata };
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
let updatedInstallation;
|
|
1873
|
+
try {
|
|
1874
|
+
// OAuth 路径下把 install metadata(含 channel_code 渠道归因)顺过来 ——
|
|
1875
|
+
// 不再走 register,更新存量 OAuth installation 的 metadata 是唯一落点。
|
|
1876
|
+
// gateway 端 updateAgentInstallation 会把白名单键(channel_code)再 promote 到
|
|
1877
|
+
// agents.metadata_json,admin panel 的归因 query 沿用原路径。
|
|
1878
|
+
// OpenClaw 路径下,metadata 已经在 handleRegister 里写过 agent + installation 了,
|
|
1879
|
+
// 这里就不再重复透传,避免把 install metadata patch 成空 / 覆盖既有值。
|
|
1880
|
+
updatedInstallation = await gateway.updateInstallation({
|
|
1881
|
+
delivery_capabilities: desiredDeliveryCapabilities,
|
|
1882
|
+
...(mergedInstallMetadata ? { metadata: mergedInstallMetadata } : {}),
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
catch (error) {
|
|
1886
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1887
|
+
const rolledBack = await rollbackInstallIdentity();
|
|
1888
|
+
return buildInstallStepFailure('update_installation', message, rolledBack);
|
|
1889
|
+
}
|
|
1327
1890
|
await persistState((current) => ({
|
|
1328
1891
|
...current,
|
|
1329
1892
|
identity: current.identity
|
|
@@ -1749,7 +2312,7 @@ async function handleEventsWait(args) {
|
|
|
1749
2312
|
async function handleCapabilityTool(name, args) {
|
|
1750
2313
|
const { platform } = await createAuthorizedClients();
|
|
1751
2314
|
const capabilities = await loadCapabilities();
|
|
1752
|
-
const capability = capabilities.find((item) => item.tool_name === name);
|
|
2315
|
+
const capability = capabilities.find((item) => item.tool_name === name && isToolVisibleOnSurface(item.tool_name));
|
|
1753
2316
|
if (!capability)
|
|
1754
2317
|
return fail('unknown_hi_capability_tool', { name });
|
|
1755
2318
|
const out = await platform.callCapability(capability.capability_id, args);
|
|
@@ -1757,8 +2320,8 @@ async function handleCapabilityTool(name, args) {
|
|
|
1757
2320
|
ok: true,
|
|
1758
2321
|
capability_id: capability.capability_id,
|
|
1759
2322
|
tool_name: capability.tool_name,
|
|
1760
|
-
result: out.result,
|
|
1761
|
-
}
|
|
2323
|
+
result: out.result ?? null,
|
|
2324
|
+
});
|
|
1762
2325
|
}
|
|
1763
2326
|
async function handleControlTool(name, args) {
|
|
1764
2327
|
switch (name) {
|
|
@@ -1772,10 +2335,16 @@ async function handleControlTool(name, args) {
|
|
|
1772
2335
|
return await handleReset(args);
|
|
1773
2336
|
case 'hi_agent_register':
|
|
1774
2337
|
return await handleRegister(args);
|
|
2338
|
+
case 'hi_agent_state_resync':
|
|
2339
|
+
return await handleStateResync(args);
|
|
1775
2340
|
case 'hi_agent_connect':
|
|
1776
2341
|
return await handleConnect(args);
|
|
1777
2342
|
case 'hi_agent_activate':
|
|
1778
2343
|
return await handleActivate(args);
|
|
2344
|
+
case 'hi_agent_claim_export':
|
|
2345
|
+
return await handleClaimExport(args);
|
|
2346
|
+
case 'hi_agent_claim_redeem':
|
|
2347
|
+
return await handleClaimRedeem(args);
|
|
1779
2348
|
case 'hi_agent_installation_get':
|
|
1780
2349
|
return await handleInstallationGet();
|
|
1781
2350
|
case 'hi_agent_installation_update':
|
|
@@ -1802,11 +2371,78 @@ async function handleControlTool(name, args) {
|
|
|
1802
2371
|
return fail('unknown_control_tool', { name });
|
|
1803
2372
|
}
|
|
1804
2373
|
}
|
|
2374
|
+
// MCP tool annotations for hi-mcp-server's hi_agent_* control tools.
|
|
2375
|
+
// OpenAI Apps SDK review hard-requires readOnlyHint/openWorldHint/
|
|
2376
|
+
// destructiveHint on every tool surfaced by an MCP server; Missing on any
|
|
2377
|
+
// of these blocks Submit. Business tools get their annotations from the
|
|
2378
|
+
// hi-platform public capability catalog (forwarded below); the control
|
|
2379
|
+
// tools are defined locally here so the annotations live here too.
|
|
2380
|
+
//
|
|
2381
|
+
// Semantics:
|
|
2382
|
+
// readOnlyHint: only reads server state, no mutation
|
|
2383
|
+
// openWorldHint: reaches outside Hi's own systems (real people via SMS/
|
|
2384
|
+
// webhook, real phone calls, third-party APIs, etc.)
|
|
2385
|
+
// destructiveHint: effect is non-reversible (data wipe, message that can't
|
|
2386
|
+
// be unsent). Only meaningful when readOnlyHint=false.
|
|
2387
|
+
const CONTROL_TOOL_ANNOTATIONS = {
|
|
2388
|
+
// Pure local-state reads. Even include_remote:true is a read-only GET
|
|
2389
|
+
// against the gateway.
|
|
2390
|
+
hi_agent_status: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Hi agent status' },
|
|
2391
|
+
// Creates a new agent identity, writes local state, optionally starts a
|
|
2392
|
+
// local receiver. All effects stay inside Hi infrastructure.
|
|
2393
|
+
hi_agent_install: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent install' },
|
|
2394
|
+
// Diagnostic. probe_delivery sends a synthetic test event targeting the
|
|
2395
|
+
// agent's own configured route; not third-party-visible.
|
|
2396
|
+
hi_agent_doctor: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Hi agent doctor' },
|
|
2397
|
+
// Wipes local persisted identity + state. Non-recoverable for this profile.
|
|
2398
|
+
hi_agent_reset: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: 'Hi agent reset' },
|
|
2399
|
+
// Creates a brand new agent + installation at the gateway. Additive.
|
|
2400
|
+
hi_agent_register: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent register' },
|
|
2401
|
+
// Reads gateway /me + /installation and patches local state.identity to match.
|
|
2402
|
+
// Pure local-state write; no gateway mutation. Idempotent.
|
|
2403
|
+
hi_agent_state_resync: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent state resync' },
|
|
2404
|
+
// Binds local profile to an existing remote agent_id. Additive.
|
|
2405
|
+
hi_agent_connect: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent connect' },
|
|
2406
|
+
// Activates the installation. Additive.
|
|
2407
|
+
hi_agent_activate: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent activate' },
|
|
2408
|
+
hi_agent_claim_export: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim export' },
|
|
2409
|
+
hi_agent_claim_redeem: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim redeem' },
|
|
2410
|
+
hi_agent_installation_get: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Installation get' },
|
|
2411
|
+
hi_agent_installation_update: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Installation update' },
|
|
2412
|
+
hi_agent_endpoints_list: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Endpoints list' },
|
|
2413
|
+
hi_agent_endpoints_upsert: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Endpoints upsert' },
|
|
2414
|
+
hi_agent_subscriptions_list: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Subscriptions list' },
|
|
2415
|
+
hi_agent_subscriptions_upsert: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Subscriptions upsert' },
|
|
2416
|
+
// Sends a test event through the configured delivery profile, which may
|
|
2417
|
+
// hit a public webhook URL the operator registered. Treat as open-world.
|
|
2418
|
+
hi_agent_test_delivery: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, title: 'Test delivery' },
|
|
2419
|
+
// Acquires a queue lease on the durable event queue. Changes server-side
|
|
2420
|
+
// lease state but not externally observable / not destructive.
|
|
2421
|
+
hi_agent_events_claim: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events claim' },
|
|
2422
|
+
// Read a claimed event's payload. Pure read.
|
|
2423
|
+
hi_agent_event_fetch: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Event fetch' },
|
|
2424
|
+
// Advances persisted cursor + marks events consumed. Not destructive
|
|
2425
|
+
// (consumer-side commit is expected normal operation).
|
|
2426
|
+
hi_agent_events_ack: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events ack' },
|
|
2427
|
+
// Long-poll claim + optional auto-ack. Changes queue state via ack.
|
|
2428
|
+
hi_agent_events_wait: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events wait' },
|
|
2429
|
+
};
|
|
1805
2430
|
async function listTools() {
|
|
1806
2431
|
const capabilities = await loadCapabilities();
|
|
2432
|
+
const toolSurface = effectiveToolSurface();
|
|
2433
|
+
const controlSurfaceTools = toolSurface === 'chatgpt_app' ? [] : controlTools();
|
|
2434
|
+
const capabilitySurfaceTools = capabilities.filter((capability) => isToolVisibleOnSurface(capability.tool_name));
|
|
1807
2435
|
return [
|
|
1808
|
-
...
|
|
1809
|
-
|
|
2436
|
+
...controlSurfaceTools.map((tool) => ({
|
|
2437
|
+
...tool,
|
|
2438
|
+
// Annotations are required by OpenAI Apps SDK review. Default to a
|
|
2439
|
+
// conservative "false / false / false" if a control tool ever gets
|
|
2440
|
+
// added here without an entry in CONTROL_TOOL_ANNOTATIONS, so review
|
|
2441
|
+
// still passes (the human reviewer can audit the conservative fallback).
|
|
2442
|
+
annotations: CONTROL_TOOL_ANNOTATIONS[tool.name]
|
|
2443
|
+
|| { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
|
|
2444
|
+
})),
|
|
2445
|
+
...capabilitySurfaceTools.map((capability) => ({
|
|
1810
2446
|
name: capability.tool_name,
|
|
1811
2447
|
title: capability.title || capability.tool_name,
|
|
1812
2448
|
description: capability.description || capability.tool_name,
|
|
@@ -1814,6 +2450,12 @@ async function listTools() {
|
|
|
1814
2450
|
inputSchema: isPlainObject(capability.parameters)
|
|
1815
2451
|
? capability.parameters
|
|
1816
2452
|
: { type: 'object', properties: {} },
|
|
2453
|
+
outputSchema: CAPABILITY_TOOL_OUTPUT_SCHEMA,
|
|
2454
|
+
// Business tools get their annotations from hi-platform's catalog
|
|
2455
|
+
// (set in hi-platform/src/mcp/schema/tools.ts). Fall back to a
|
|
2456
|
+
// conservative default for any capability missing them.
|
|
2457
|
+
annotations: capability.annotations
|
|
2458
|
+
|| { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
|
|
1817
2459
|
})),
|
|
1818
2460
|
];
|
|
1819
2461
|
}
|
|
@@ -1836,6 +2478,12 @@ function createMcpServer() {
|
|
|
1836
2478
|
const name = normalizeText(request.params.name);
|
|
1837
2479
|
const args = normalizeRecord(request.params.arguments);
|
|
1838
2480
|
try {
|
|
2481
|
+
if (!isToolVisibleOnSurface(name)) {
|
|
2482
|
+
return fail('tool_not_available_on_surface', {
|
|
2483
|
+
name,
|
|
2484
|
+
tool_surface: effectiveToolSurface(),
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
1839
2487
|
const controlToolNames = new Set(controlTools().map((tool) => tool.name));
|
|
1840
2488
|
return controlToolNames.has(name)
|
|
1841
2489
|
? await handleControlTool(name, args)
|
|
@@ -1847,13 +2495,171 @@ function createMcpServer() {
|
|
|
1847
2495
|
});
|
|
1848
2496
|
return server;
|
|
1849
2497
|
}
|
|
2498
|
+
async function handleMcpRequest(req, res) {
|
|
2499
|
+
const server = createMcpServer();
|
|
2500
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2501
|
+
sessionIdGenerator: undefined,
|
|
2502
|
+
});
|
|
2503
|
+
try {
|
|
2504
|
+
await server.connect(transport);
|
|
2505
|
+
await transport.handleRequest(req, res, req.body);
|
|
2506
|
+
res.on('close', () => {
|
|
2507
|
+
void transport.close().catch(() => undefined);
|
|
2508
|
+
void server.close().catch(() => undefined);
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
catch (error) {
|
|
2512
|
+
if (!res.headersSent) {
|
|
2513
|
+
res.status(500).json({
|
|
2514
|
+
jsonrpc: '2.0',
|
|
2515
|
+
error: {
|
|
2516
|
+
code: -32603,
|
|
2517
|
+
message: normalizeText(error?.message) || 'internal_server_error',
|
|
2518
|
+
},
|
|
2519
|
+
id: null,
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
1850
2524
|
async function runHttpServer() {
|
|
1851
2525
|
const app = createMcpExpressApp({ host: config.host });
|
|
2526
|
+
// CORS — every request gets permissive headers + preflight short-circuit.
|
|
2527
|
+
// Why: claude.ai's web connector (and other browser-based MCP UIs) talks
|
|
2528
|
+
// to /mcp from a https://claude.ai origin. Without these the browser
|
|
2529
|
+
// silently drops responses; the connector reports a generic "Couldn't
|
|
2530
|
+
// reach the MCP server" error even though the server is reachable.
|
|
2531
|
+
// Bearer-auth-only, no cookies, so wildcard origin is safe (we cannot
|
|
2532
|
+
// combine `*` with credentials, which we never set).
|
|
2533
|
+
// WWW-Authenticate MUST be in Expose-Headers so browser JS can read the
|
|
2534
|
+
// RFC 9728 resource_metadata hint on a 401 and start the OAuth flow.
|
|
2535
|
+
app.use((req, res, next) => {
|
|
2536
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2537
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
2538
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID');
|
|
2539
|
+
res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version');
|
|
2540
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
2541
|
+
res.setHeader('Vary', 'Origin');
|
|
2542
|
+
if (req.method === 'OPTIONS') {
|
|
2543
|
+
res.status(204).end();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
next();
|
|
2547
|
+
});
|
|
2548
|
+
// RFC 9728 — Protected Resource Metadata. Codex / Claude Code discover
|
|
2549
|
+
// the Authorization Server from here after the first 401 challenge.
|
|
2550
|
+
// Public surface, no auth, cached briefly.
|
|
2551
|
+
//
|
|
2552
|
+
// Multi-host: each configured resource URL gets its own PRM payload so a
|
|
2553
|
+
// request that arrives via mcp.hirey.ai sees `resource:
|
|
2554
|
+
// "https://mcp.hirey.ai/mcp"` while a request via the legacy
|
|
2555
|
+
// hi.hirey.ai alias sees `resource: "https://hi.hirey.ai/mcp"`. The
|
|
2556
|
+
// alternative — a single PRM advertising the canonical URL on the alias
|
|
2557
|
+
// host — fails RFC 9728 §3.3 strict clients (Codex included) because the
|
|
2558
|
+
// resource URL no longer matches the host that served the metadata.
|
|
2559
|
+
const protectedResourceMetadataPath = '/.well-known/oauth-protected-resource';
|
|
2560
|
+
const oauthResourceTable = config.authMode === 'oauth'
|
|
2561
|
+
? config.oauthMcpResources.map((resourceUrl) => {
|
|
2562
|
+
const trimmed = resourceUrl.replace(/\/+$/, '');
|
|
2563
|
+
const baseNoMcp = trimmed.replace(/\/mcp$/, '');
|
|
2564
|
+
const host = new URL(resourceUrl).host.toLowerCase();
|
|
2565
|
+
return {
|
|
2566
|
+
resource: resourceUrl,
|
|
2567
|
+
host,
|
|
2568
|
+
protectedResourceMetadataUrl: `${baseNoMcp}${protectedResourceMetadataPath}`,
|
|
2569
|
+
metadata: buildProtectedResourceMetadata({
|
|
2570
|
+
resource: resourceUrl,
|
|
2571
|
+
authorizationServer: config.oauthAuthorizationServer,
|
|
2572
|
+
scopes: config.oauthScopesSupported,
|
|
2573
|
+
resourceDocumentationUrl: `${config.platformBaseUrl.replace(/\/+$/, '')}/docs/mcp`,
|
|
2574
|
+
}),
|
|
2575
|
+
};
|
|
2576
|
+
})
|
|
2577
|
+
: [];
|
|
2578
|
+
// Pick the resource entry whose hostname matches the inbound request, so
|
|
2579
|
+
// a 401 challenge served on mcp.hirey.ai points back at
|
|
2580
|
+
// mcp.hirey.ai/.well-known/oauth-protected-resource. Falls back to the
|
|
2581
|
+
// primary (index 0) when the request host isn't in the table — that path
|
|
2582
|
+
// matters for direct cluster-internal probes and dev curl calls without
|
|
2583
|
+
// a Host header.
|
|
2584
|
+
function resolveOAuthResource(req) {
|
|
2585
|
+
if (oauthResourceTable.length === 0)
|
|
2586
|
+
return null;
|
|
2587
|
+
const hostHeader = String(req.headers.host || '').toLowerCase().split(':')[0];
|
|
2588
|
+
return oauthResourceTable.find((e) => e.host === hostHeader) || oauthResourceTable[0];
|
|
2589
|
+
}
|
|
2590
|
+
if (config.authMode === 'oauth') {
|
|
2591
|
+
app.get(protectedResourceMetadataPath, (req, res) => {
|
|
2592
|
+
const entry = resolveOAuthResource(req);
|
|
2593
|
+
if (!entry) {
|
|
2594
|
+
res.status(503).json({ error: 'oauth_resource_unconfigured' });
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
2598
|
+
res.json(entry.metadata);
|
|
2599
|
+
});
|
|
2600
|
+
const authorizationServerMetadataAlias = buildAuthorizationServerMetadataAlias({
|
|
2601
|
+
issuer: config.oauthIssuer,
|
|
2602
|
+
authorizationServer: config.oauthAuthorizationServer,
|
|
2603
|
+
scopes: config.oauthScopesSupported,
|
|
2604
|
+
serviceDocumentationUrl: `${config.oauthAuthorizationServer.replace(/\/+$/, '')}/docs/oauth`,
|
|
2605
|
+
});
|
|
2606
|
+
for (const metadataPath of [
|
|
2607
|
+
'/.well-known/oauth-authorization-server',
|
|
2608
|
+
'/.well-known/oauth-authorization-server/mcp',
|
|
2609
|
+
'/.well-known/oauth-authorization-server/mcp/chatgpt',
|
|
2610
|
+
'/mcp/.well-known/oauth-authorization-server',
|
|
2611
|
+
'/mcp/chatgpt/.well-known/oauth-authorization-server',
|
|
2612
|
+
]) {
|
|
2613
|
+
app.get(metadataPath, (_req, res) => {
|
|
2614
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
2615
|
+
res.json(authorizationServerMetadataAlias);
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
// Per-request OAuth verifier — JWKS is cached inside the closure so we
|
|
2620
|
+
// pay the network round trip once, not per request. Skipped entirely in
|
|
2621
|
+
// 'local' mode so stdio + legacy installs incur zero overhead.
|
|
2622
|
+
const oauthVerifier = config.authMode === 'oauth'
|
|
2623
|
+
? buildOAuthVerifier({
|
|
2624
|
+
// JWT `iss` check — must match what hi-auth signs into tokens. In
|
|
2625
|
+
// prod this is the legacy issuer (hi.hirey.ai) so OpenClaw and
|
|
2626
|
+
// every other client_credentials caller keep working. Discovery
|
|
2627
|
+
// metadata can live on a different host (oauthAuthorizationServer).
|
|
2628
|
+
issuer: config.oauthIssuer,
|
|
2629
|
+
// Audience check accepts ANY of the configured resource URLs.
|
|
2630
|
+
// jose.jwtVerify with array audience matches if at least one is
|
|
2631
|
+
// present in the token's `aud` claim (RFC 7519 §4.1.3).
|
|
2632
|
+
audience: config.oauthMcpResources,
|
|
2633
|
+
})
|
|
2634
|
+
: null;
|
|
2635
|
+
function send401(req, res, description, error = 'invalid_token') {
|
|
2636
|
+
if (config.authMode === 'oauth') {
|
|
2637
|
+
const entry = resolveOAuthResource(req);
|
|
2638
|
+
const challenge = buildWwwAuthenticateChallenge({
|
|
2639
|
+
protectedResourceMetadataUrl: entry?.protectedResourceMetadataUrl || '',
|
|
2640
|
+
scope: config.oauthScopesSupported,
|
|
2641
|
+
error,
|
|
2642
|
+
errorDescription: description,
|
|
2643
|
+
});
|
|
2644
|
+
res.setHeader('WWW-Authenticate', challenge);
|
|
2645
|
+
}
|
|
2646
|
+
res.status(401).json({
|
|
2647
|
+
jsonrpc: '2.0',
|
|
2648
|
+
error: {
|
|
2649
|
+
code: -32001,
|
|
2650
|
+
message: error,
|
|
2651
|
+
...(description ? { data: { description } } : {}),
|
|
2652
|
+
},
|
|
2653
|
+
id: null,
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
1852
2656
|
app.get('/healthz', (_req, res) => {
|
|
1853
2657
|
res.json({
|
|
1854
2658
|
ok: true,
|
|
1855
2659
|
profile: config.profile,
|
|
1856
2660
|
transport: config.transport,
|
|
2661
|
+
auth_mode: config.authMode,
|
|
2662
|
+
tool_surface: config.toolSurface,
|
|
1857
2663
|
});
|
|
1858
2664
|
});
|
|
1859
2665
|
// readiness 必须确认上游 discovery 可达;否则 pod 虽然活着,但实际上没法服务真实 MCP 请求。
|
|
@@ -1864,6 +2670,8 @@ async function runHttpServer() {
|
|
|
1864
2670
|
ok: true,
|
|
1865
2671
|
profile: config.profile,
|
|
1866
2672
|
transport: config.transport,
|
|
2673
|
+
auth_mode: config.authMode,
|
|
2674
|
+
tool_surface: config.toolSurface,
|
|
1867
2675
|
registry_base_url: wellKnown.platform.registry_base_url,
|
|
1868
2676
|
});
|
|
1869
2677
|
}
|
|
@@ -1874,52 +2682,61 @@ async function runHttpServer() {
|
|
|
1874
2682
|
});
|
|
1875
2683
|
}
|
|
1876
2684
|
});
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
sessionIdGenerator: undefined,
|
|
2685
|
+
function runMcpRequest(req, res, toolSurface) {
|
|
2686
|
+
requestToolSurfaceStorage.run(toolSurface, () => {
|
|
2687
|
+
void handleMcpRequest(req, res);
|
|
1881
2688
|
});
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
catch (error) {
|
|
1891
|
-
if (!res.headersSent) {
|
|
1892
|
-
res.status(500).json({
|
|
1893
|
-
jsonrpc: '2.0',
|
|
1894
|
-
error: {
|
|
1895
|
-
code: -32603,
|
|
1896
|
-
message: normalizeText(error?.message) || 'internal_server_error',
|
|
1897
|
-
},
|
|
1898
|
-
id: null,
|
|
1899
|
-
});
|
|
2689
|
+
}
|
|
2690
|
+
async function handleMcpPost(req, res, toolSurface = null) {
|
|
2691
|
+
// OAuth-mode bearer enforcement. Stdio path and 'local' HTTP path keep
|
|
2692
|
+
// the existing single-tenant disk identity model untouched.
|
|
2693
|
+
if (config.authMode === 'oauth' && oauthVerifier) {
|
|
2694
|
+
const outcome = await verifyRequestBearer(req, oauthVerifier);
|
|
2695
|
+
if (!outcome.ok) {
|
|
2696
|
+
return send401(req, res, outcome.description);
|
|
1900
2697
|
}
|
|
2698
|
+
requestAuthStorage.run(outcome.auth, () => {
|
|
2699
|
+
runMcpRequest(req, res, toolSurface);
|
|
2700
|
+
});
|
|
2701
|
+
return;
|
|
1901
2702
|
}
|
|
2703
|
+
runMcpRequest(req, res, toolSurface);
|
|
2704
|
+
}
|
|
2705
|
+
// Dedicated ChatGPT App / Apps SDK review surface. Keep `/mcp` as the
|
|
2706
|
+
// full Codex/OpenClaw/Claude-compatible endpoint so existing remote MCP
|
|
2707
|
+
// users do not lose onboarding and event tools.
|
|
2708
|
+
app.post('/mcp/chatgpt', async (req, res) => {
|
|
2709
|
+
await handleMcpPost(req, res, 'chatgpt_app');
|
|
1902
2710
|
});
|
|
1903
|
-
app.
|
|
1904
|
-
res
|
|
1905
|
-
jsonrpc: '2.0',
|
|
1906
|
-
error: {
|
|
1907
|
-
code: -32000,
|
|
1908
|
-
message: 'Method not allowed.',
|
|
1909
|
-
},
|
|
1910
|
-
id: null,
|
|
1911
|
-
});
|
|
2711
|
+
app.post('/mcp', async (req, res) => {
|
|
2712
|
+
await handleMcpPost(req, res);
|
|
1912
2713
|
});
|
|
1913
|
-
|
|
2714
|
+
// A GET/DELETE to the MCP endpoint targets a protected resource. When the
|
|
2715
|
+
// request is unauthenticated in oauth mode, answer with the 401 +
|
|
2716
|
+
// WWW-Authenticate challenge — same as the POST path — so a client that
|
|
2717
|
+
// probes the endpoint via GET (rather than an initialize POST) can still
|
|
2718
|
+
// discover the OAuth protected-resource metadata, instead of bouncing off a
|
|
2719
|
+
// bare 405 that hides the auth requirement. The streamable-HTTP transport is
|
|
2720
|
+
// stateless and only serves POST, so an *authenticated* GET/DELETE still
|
|
2721
|
+
// legitimately gets 405 (method not allowed for this transport).
|
|
2722
|
+
const methodNotAllowed = (res) => {
|
|
1914
2723
|
res.status(405).json({
|
|
1915
2724
|
jsonrpc: '2.0',
|
|
1916
|
-
error: {
|
|
1917
|
-
code: -32000,
|
|
1918
|
-
message: 'Method not allowed.',
|
|
1919
|
-
},
|
|
2725
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
1920
2726
|
id: null,
|
|
1921
2727
|
});
|
|
1922
|
-
}
|
|
2728
|
+
};
|
|
2729
|
+
const challengeOrMethodNotAllowed = async (req, res) => {
|
|
2730
|
+
if (config.authMode === 'oauth' && oauthVerifier) {
|
|
2731
|
+
const outcome = await verifyRequestBearer(req, oauthVerifier);
|
|
2732
|
+
if (!outcome.ok) {
|
|
2733
|
+
return send401(req, res, outcome.description);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
methodNotAllowed(res);
|
|
2737
|
+
};
|
|
2738
|
+
app.get(['/mcp', '/mcp/chatgpt'], challengeOrMethodNotAllowed);
|
|
2739
|
+
app.delete(['/mcp', '/mcp/chatgpt'], challengeOrMethodNotAllowed);
|
|
1923
2740
|
const httpServer = createServer(app);
|
|
1924
2741
|
await new Promise((resolve) => {
|
|
1925
2742
|
httpServer.listen(config.port, config.host, () => resolve());
|