aws-runtime-bridge 1.9.124 → 1.9.126
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 +23 -2
- package/dist/adapter/AcodeSdkAdapter.js +4 -4
- package/dist/adapter/OpencodeSdkAdapter.js +2 -2
- package/dist/adapter/types.js +1 -1
- package/dist/config.d.ts +30 -4
- package/dist/config.js +3 -1
- package/dist/index.js +1 -1
- package/dist/routes/acode-todos.js +1 -1
- package/dist/routes/background-tasks.js +1 -1
- package/dist/routes/dashboard.js +58 -3
- package/dist/routes/git.d.ts +28 -0
- package/dist/routes/git.js +32 -31
- package/dist/routes/terminal.js +24 -24
- package/dist/services/auto-register.d.ts +2 -2
- package/dist/services/auto-register.js +1 -1
- package/dist/services/aws-client-agent-mcp.d.ts +2 -1
- package/dist/services/aws-client-agent-mcp.js +2 -2
- package/dist/services/cc-switch-sdk.js +1 -1
- package/dist/services/cli-commands.js +5 -5
- package/dist/services/fileSnapshotStore.js +1 -1
- package/dist/services/instance-ai-config-service.js +1 -1
- package/dist/services/instance-features.d.ts +2 -0
- package/dist/services/instance-features.js +1 -1
- package/dist/services/panel-auth.d.ts +2 -0
- package/dist/services/panel-auth.js +1 -1
- package/dist/services/startup-config-wizard.js +6 -6
- package/dist/services/tunnel-client.js +1 -1
- package/dist/services/update-notifier.js +2 -2
- package/package/acode/dist/ask-user-question-tool.js +2 -2
- package/package/acode/dist/background-command-store.js +1 -1
- package/package/acode/dist/builtins/commandArtifacts.js +3 -3
- package/package/acode/dist/mcpToolNames.d.ts +2 -0
- package/package/acode/dist/mcpToolNames.js +1 -1
- package/package/acode/dist/meeting-debug-log.d.ts +1 -0
- package/package/acode/dist/meeting-debug-log.js +3 -0
- package/package/acode/dist/paths.d.ts +2 -0
- package/package/acode/dist/paths.js +2 -0
- package/package/acode/dist/runtime.d.ts +4 -0
- package/package/acode/dist/runtime.js +20 -20
- package/package/acode/dist/sub-agent-manager.d.ts +1 -0
- package/package/acode/dist/sub-agent-manager.js +1 -1
- package/package/acode/dist/sub-agent-store.js +3 -3
- package/package/acode/dist/sub-agent-tools.js +27 -31
- package/package/acode/dist/sub-agent-types.d.ts +1 -1
- package/package/acode/dist/todo-store.js +1 -1
- package/package/acode/dist/tool-allocation.d.ts +18 -4
- package/package/acode/dist/tool-allocation.js +1 -1
- package/package/acode/dist/types.d.ts +0 -3
- package/package/aws-client-agent-mcp/dist/agent-client.d.ts +17 -0
- package/package/aws-client-agent-mcp/dist/agent-client.js +1 -1
- package/package/aws-client-agent-mcp/dist/agent-client.test.js +251 -1
- package/package/aws-client-agent-mcp/dist/config.d.ts +2 -0
- package/package/aws-client-agent-mcp/dist/config.js +1 -1
- package/package/aws-client-agent-mcp/dist/config.test.js +93 -1
- package/package/aws-client-agent-mcp/dist/http-client.d.ts +2 -0
- package/package/aws-client-agent-mcp/dist/http-client.js +1 -1
- package/package/aws-client-agent-mcp/dist/launch-config-tools.test.js +6 -2
- package/package/aws-client-agent-mcp/dist/mcp-server.d.ts +21 -0
- package/package/aws-client-agent-mcp/dist/mcp-server.js +28 -28
- package/package/aws-client-agent-mcp/dist/mcp-server.test.js +628 -7
- package/package/aws-client-agent-mcp/dist/mcp-tools.d.ts +10 -0
- package/package/aws-client-agent-mcp/dist/mcp-tools.js +12 -10
- package/package/aws-client-agent-mcp/dist/mcp-tools.test.js +38 -0
- package/package/aws-client-agent-mcp/dist/meeting-debug-log.d.ts +13 -0
- package/package/aws-client-agent-mcp/dist/meeting-debug-log.js +3 -0
- package/package/aws-client-agent-mcp/dist/message-buffer.d.ts +2 -0
- package/package/aws-client-agent-mcp/dist/message-buffer.js +1 -1
- package/package/aws-client-agent-mcp/dist/message-buffer.test.js +60 -0
- package/package/aws-client-agent-mcp/dist/runtime-launch-binding.d.ts +14 -0
- package/package/aws-client-agent-mcp/dist/runtime-launch-binding.js +1 -1
- package/package/aws-client-agent-mcp/dist/runtime-launch-binding.test.js +39 -1
- package/package/aws-client-agent-mcp/dist/types.d.ts +11 -1
- package/package/aws-client-agent-mcp/dist/websocket-client.js +1 -1
- package/package/aws-client-agent-mcp/package.json +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
|
3
|
-
import { AgentClient } from "./agent-client.js";
|
|
3
|
+
import { AgentClient, PollLongPollTimeoutError } from "./agent-client.js";
|
|
4
4
|
import { setMcpNodeLogSink } from "./logger.js";
|
|
5
5
|
// Helper function to create a complete ProfileInfo
|
|
6
6
|
function createProfileInfo(overrides = {}) {
|
|
@@ -55,6 +55,78 @@ describe("AgentClient", () => {
|
|
|
55
55
|
assert.equal(client.getAgentConfig().agentId, "preset-agent-with-token");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
describe("initialize with unreachable bridge", () => {
|
|
59
|
+
function setupFastClaimEnv() {
|
|
60
|
+
process.env.AWS_AGENT_ID = "preset-agent-retry";
|
|
61
|
+
process.env.AWS_RUNTIME_BRIDGE_BASE_URL = "http://127.0.0.1:18081";
|
|
62
|
+
process.env.AWS_MCP_LAUNCH_BINDING_CLAIM_ATTEMPTS = "1";
|
|
63
|
+
process.env.AWS_MCP_LAUNCH_BINDING_CLAIM_RETRY_DELAY_MS = "0";
|
|
64
|
+
}
|
|
65
|
+
it("keeps bridge base url and pends claim when bridge is unreachable at startup", async () => {
|
|
66
|
+
setupFastClaimEnv();
|
|
67
|
+
mock.method(globalThis, "fetch", async () => {
|
|
68
|
+
throw new Error("fetch failed");
|
|
69
|
+
});
|
|
70
|
+
const client = new AgentClient();
|
|
71
|
+
await client.initialize();
|
|
72
|
+
// 关键回归点:不能清空 runtimeBridgeBaseUrl,否则后续永远拿不到 token,
|
|
73
|
+
// 日志目录会停留在 unknown-role-* 且所有工具调用 401。
|
|
74
|
+
assert.equal(client.getServerConfig().runtimeBridgeBaseUrl, "http://127.0.0.1:18081");
|
|
75
|
+
});
|
|
76
|
+
it("applies reclaimed launch binding once bridge recovers", async () => {
|
|
77
|
+
setupFastClaimEnv();
|
|
78
|
+
let bridgeUp = false;
|
|
79
|
+
mock.method(globalThis, "fetch", async (url) => {
|
|
80
|
+
const urlText = url.toString();
|
|
81
|
+
if (!urlText.includes("/runtime/binding/claim-agent")) {
|
|
82
|
+
throw new Error(`unexpected fetch target: ${urlText}`);
|
|
83
|
+
}
|
|
84
|
+
if (!bridgeUp) {
|
|
85
|
+
throw new Error("fetch failed");
|
|
86
|
+
}
|
|
87
|
+
return new Response(JSON.stringify({
|
|
88
|
+
agentId: "preset-agent-retry",
|
|
89
|
+
workspacePath: "/workspace/demo",
|
|
90
|
+
runtimeAccessToken: "reclaimed-token-1234567890",
|
|
91
|
+
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
92
|
+
});
|
|
93
|
+
const client = new AgentClient();
|
|
94
|
+
await client.initialize();
|
|
95
|
+
assert.equal(client.getServerConfig().runtimeAccessToken, undefined);
|
|
96
|
+
bridgeUp = true;
|
|
97
|
+
// register() 每次尝试前会走同一条补认领路径;这里直接调用私有方法聚焦断言。
|
|
98
|
+
await Reflect.get(client, "retryPendingLaunchBindingClaim").call(client);
|
|
99
|
+
assert.equal(client.getServerConfig().runtimeAccessToken, "reclaimed-token-1234567890");
|
|
100
|
+
});
|
|
101
|
+
it("still registers via preset agent id while bridge stays unreachable", async () => {
|
|
102
|
+
setupFastClaimEnv();
|
|
103
|
+
mock.method(globalThis, "fetch", async () => {
|
|
104
|
+
throw new Error("fetch failed");
|
|
105
|
+
});
|
|
106
|
+
const client = new AgentClient();
|
|
107
|
+
await client.initialize();
|
|
108
|
+
let getProfileCalls = 0;
|
|
109
|
+
Reflect.set(client, "httpClient", {
|
|
110
|
+
callTool: async (tool) => {
|
|
111
|
+
if (tool !== "get_profile") {
|
|
112
|
+
throw new Error(`unexpected tool: ${tool}`);
|
|
113
|
+
}
|
|
114
|
+
getProfileCalls += 1;
|
|
115
|
+
return createProfileInfo({ id: "preset-agent-retry" });
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
Reflect.set(client, "wsClient", {
|
|
119
|
+
connect: async () => { },
|
|
120
|
+
disconnect: () => { },
|
|
121
|
+
isConnected: () => true,
|
|
122
|
+
on: () => { },
|
|
123
|
+
});
|
|
124
|
+
const result = await client.register();
|
|
125
|
+
assert.equal(result.agentId, "preset-agent-retry");
|
|
126
|
+
assert.ok(getProfileCalls >= 1);
|
|
127
|
+
assert.equal(client.getServerConfig().runtimeBridgeBaseUrl, "http://127.0.0.1:18081");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
58
130
|
describe("register", () => {
|
|
59
131
|
it("should register as new agent when no preset agentId", async () => {
|
|
60
132
|
process.env.AWS_ROLE_NAME = "test-role";
|
|
@@ -188,6 +260,63 @@ describe("AgentClient", () => {
|
|
|
188
260
|
assert.equal(disconnectCalled, true);
|
|
189
261
|
});
|
|
190
262
|
});
|
|
263
|
+
describe("callAgentScopedTool", () => {
|
|
264
|
+
it("should always use the registered agent id over a caller-supplied id", async () => {
|
|
265
|
+
const client = new AgentClient();
|
|
266
|
+
Reflect.set(client, "agentId", "registered-agent");
|
|
267
|
+
let capturedArgs;
|
|
268
|
+
Reflect.set(client, "httpClient", {
|
|
269
|
+
callTool: async (_toolName, args) => {
|
|
270
|
+
capturedArgs = args;
|
|
271
|
+
return { success: true };
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
await client.callAgentScopedTool("meeting_speak", {
|
|
275
|
+
agentId: "spoofed-agent",
|
|
276
|
+
meetingId: "meeting-1",
|
|
277
|
+
content: "hello",
|
|
278
|
+
});
|
|
279
|
+
assert.equal(capturedArgs?.agentId, "registered-agent");
|
|
280
|
+
assert.equal(capturedArgs?.meetingId, "meeting-1");
|
|
281
|
+
assert.equal(capturedArgs?.content, "hello");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe("hire identity injection", () => {
|
|
285
|
+
it("hireColleague should always use the registered agent id over a caller-supplied id", async () => {
|
|
286
|
+
const client = new AgentClient();
|
|
287
|
+
Reflect.set(client, "agentId", "registered-agent");
|
|
288
|
+
let capturedArgs;
|
|
289
|
+
Reflect.set(client, "httpClient", {
|
|
290
|
+
callTool: async (_toolName, args) => {
|
|
291
|
+
capturedArgs = args;
|
|
292
|
+
return { success: true };
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
await client.hireColleague({
|
|
296
|
+
agentId: "spoofed-agent",
|
|
297
|
+
workspacePath: "/tmp/ws",
|
|
298
|
+
});
|
|
299
|
+
assert.equal(capturedArgs?.agentId, "registered-agent");
|
|
300
|
+
assert.equal(capturedArgs?.workspacePath, "/tmp/ws");
|
|
301
|
+
});
|
|
302
|
+
it("hireColleaguesBatch should always use the registered agent id over a caller-supplied id", async () => {
|
|
303
|
+
const client = new AgentClient();
|
|
304
|
+
Reflect.set(client, "agentId", "registered-agent");
|
|
305
|
+
let capturedArgs;
|
|
306
|
+
Reflect.set(client, "httpClient", {
|
|
307
|
+
callTool: async (_toolName, args) => {
|
|
308
|
+
capturedArgs = args;
|
|
309
|
+
return { success: true };
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
await client.hireColleaguesBatch({
|
|
313
|
+
agentId: "spoofed-agent",
|
|
314
|
+
items: [{ workspacePath: "/tmp/ws" }],
|
|
315
|
+
});
|
|
316
|
+
assert.equal(capturedArgs?.agentId, "registered-agent");
|
|
317
|
+
assert.deepEqual(capturedArgs?.items, [{ workspacePath: "/tmp/ws" }]);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
191
320
|
describe("sendGroupMessage", () => {
|
|
192
321
|
it("should send group message with correct parameters", async () => {
|
|
193
322
|
const client = new AgentClient();
|
|
@@ -362,6 +491,127 @@ describe("AgentClient", () => {
|
|
|
362
491
|
assert.deepEqual(result, { directMessages: [], groupMessages: [] });
|
|
363
492
|
assert.deepEqual(calledTools, ["poll_message", "discover_group_rooms"]);
|
|
364
493
|
});
|
|
494
|
+
it("should pass preemptiveJoinWaitMs and pollMessageTimeoutMs through to poll_message", async () => {
|
|
495
|
+
const client = new AgentClient();
|
|
496
|
+
Reflect.set(client, "agentId", "agent-1");
|
|
497
|
+
Reflect.set(client, "projectId", "demo");
|
|
498
|
+
const capturedCalls = [];
|
|
499
|
+
Reflect.set(client, "httpClient", {
|
|
500
|
+
callTool: async () => ({}),
|
|
501
|
+
callToolOnce: async (toolName, args, timeoutMs) => {
|
|
502
|
+
capturedCalls.push({ toolName, args, timeoutMs });
|
|
503
|
+
if (toolName === "poll_message") {
|
|
504
|
+
return { messages: [] };
|
|
505
|
+
}
|
|
506
|
+
if (toolName === "discover_group_rooms") {
|
|
507
|
+
return { groups: [] };
|
|
508
|
+
}
|
|
509
|
+
return {};
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
await client.fetchUnreadMessages({
|
|
513
|
+
includePipelineTasks: true,
|
|
514
|
+
preemptiveJoinWaitMs: 0,
|
|
515
|
+
pollMessageTimeoutMs: 65_000,
|
|
516
|
+
skipGroupMessages: true,
|
|
517
|
+
});
|
|
518
|
+
const pollCall = capturedCalls.find((call) => call.toolName === "poll_message");
|
|
519
|
+
assert.ok(pollCall, "poll_message 应被调用");
|
|
520
|
+
// 显式 0 必须下发(语义为"立即加入但不阻塞等待"):省略会让服务端回退默认阻塞预算,
|
|
521
|
+
// 后台周期性 hydrate 在服务端堆积长阻塞 Callable
|
|
522
|
+
assert.equal(pollCall.args.preemptiveJoinWaitMs, 0);
|
|
523
|
+
// 会议邀请强制 hydrate 的单请求超时需覆盖服务端等待预算,否则合法长请求被误判超时
|
|
524
|
+
assert.equal(pollCall.timeoutMs, 65_000);
|
|
525
|
+
// 会议快速 hydrate 跳过群聊发现,避免群阶段耗时拖慢抢占返回
|
|
526
|
+
assert.equal(capturedCalls.some((call) => call.toolName === "discover_group_rooms"), false);
|
|
527
|
+
});
|
|
528
|
+
it("should propagate client poll_message timeout as PollLongPollTimeoutError when requested", async () => {
|
|
529
|
+
const client = new AgentClient();
|
|
530
|
+
Reflect.set(client, "agentId", "agent-1");
|
|
531
|
+
Reflect.set(client, "projectId", "demo");
|
|
532
|
+
const mockCallPollTimeout = async (toolName) => {
|
|
533
|
+
if (toolName === "poll_message") {
|
|
534
|
+
throw new Error("MCP tool poll_message timed out after 65000ms");
|
|
535
|
+
}
|
|
536
|
+
if (toolName === "discover_group_rooms") {
|
|
537
|
+
return { groups: [] };
|
|
538
|
+
}
|
|
539
|
+
return {};
|
|
540
|
+
};
|
|
541
|
+
Reflect.set(client, "httpClient", {
|
|
542
|
+
callTool: mockCallPollTimeout,
|
|
543
|
+
callToolOnce: mockCallPollTimeout,
|
|
544
|
+
});
|
|
545
|
+
// 超时必须与"服务端快速返回空"区分开:超时说明服务端大概率仍在执行同一请求
|
|
546
|
+
// (Callable 占线至预算结束),立即重试只会在服务端叠加长阻塞线程
|
|
547
|
+
await assert.rejects(() => client.fetchUnreadMessages({
|
|
548
|
+
includePipelineTasks: true,
|
|
549
|
+
propagatePollTimeout: true,
|
|
550
|
+
}), (error) => {
|
|
551
|
+
assert.ok(error instanceof PollLongPollTimeoutError);
|
|
552
|
+
assert.match(error.message, /timed out after 65000ms/);
|
|
553
|
+
return true;
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
it("should propagate server poll_message 503 as PollLongPollTimeoutError when requested", async () => {
|
|
557
|
+
const client = new AgentClient();
|
|
558
|
+
Reflect.set(client, "agentId", "agent-1");
|
|
559
|
+
Reflect.set(client, "projectId", "demo");
|
|
560
|
+
const mockCallPoll503 = async (toolName) => {
|
|
561
|
+
if (toolName === "poll_message") {
|
|
562
|
+
throw new Error('MCP tool poll_message call failed: 503 Service Unavailable - {"error":"Async request timed out"}');
|
|
563
|
+
}
|
|
564
|
+
if (toolName === "discover_group_rooms") {
|
|
565
|
+
return { groups: [] };
|
|
566
|
+
}
|
|
567
|
+
return {};
|
|
568
|
+
};
|
|
569
|
+
Reflect.set(client, "httpClient", {
|
|
570
|
+
callTool: mockCallPoll503,
|
|
571
|
+
callToolOnce: mockCallPoll503,
|
|
572
|
+
});
|
|
573
|
+
await assert.rejects(() => client.fetchUnreadMessages({
|
|
574
|
+
includePipelineTasks: true,
|
|
575
|
+
propagatePollTimeout: true,
|
|
576
|
+
}), PollLongPollTimeoutError);
|
|
577
|
+
});
|
|
578
|
+
it("should keep fetched DMs when discover_group_rooms fails in group phase", async () => {
|
|
579
|
+
const client = new AgentClient();
|
|
580
|
+
Reflect.set(client, "agentId", "agent-1");
|
|
581
|
+
Reflect.set(client, "projectId", "demo");
|
|
582
|
+
const meetingNotice = {
|
|
583
|
+
msgId: 42,
|
|
584
|
+
channel: "meeting",
|
|
585
|
+
meetingId: "m-9",
|
|
586
|
+
senderId: "MEETING-SYSTEM",
|
|
587
|
+
senderName: "会议系统",
|
|
588
|
+
content: "系统已自动为你加入会议「评审会」。",
|
|
589
|
+
isSystem: true,
|
|
590
|
+
timestamp: new Date().toISOString(),
|
|
591
|
+
};
|
|
592
|
+
Reflect.set(client, "httpClient", {
|
|
593
|
+
callTool: async (toolName) => {
|
|
594
|
+
if (toolName === "discover_group_rooms") {
|
|
595
|
+
throw new Error("MCP tool discover_group_rooms call failed: 500 Internal Server Error");
|
|
596
|
+
}
|
|
597
|
+
return {};
|
|
598
|
+
},
|
|
599
|
+
callToolOnce: async (toolName) => {
|
|
600
|
+
if (toolName === "poll_message") {
|
|
601
|
+
return { messages: [meetingNotice] };
|
|
602
|
+
}
|
|
603
|
+
if (toolName === "discover_group_rooms") {
|
|
604
|
+
throw new Error("MCP tool discover_group_rooms call failed: 500 Internal Server Error");
|
|
605
|
+
}
|
|
606
|
+
return {};
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
// 群阶段失败必须降级为无群消息,绝不能抛出导致已取到的私信(含会议自动加入提示)被整体丢弃
|
|
610
|
+
const result = await client.fetchUnreadMessages({ includePipelineTasks: true });
|
|
611
|
+
assert.equal(result.directMessages.length, 1);
|
|
612
|
+
assert.deepEqual(result.directMessages[0], meetingNotice);
|
|
613
|
+
assert.deepEqual(result.groupMessages, []);
|
|
614
|
+
});
|
|
365
615
|
it("should not recover non-poll_message 503 errors", async () => {
|
|
366
616
|
const client = new AgentClient();
|
|
367
617
|
Reflect.set(client, "agentId", "agent-1");
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{logger as
|
|
1
|
+
import d from"node:fs";import _ from"node:os";import g from"node:path";import{logger as s}from"./logger.js";const A="ws://localhost:8080/ws/agent",C=3e3,R=9e3,v=200,I=3e4,S=1e3,U=-1,w=12*60*60*1e3,M=12e4,h=18081;function N(){const n=String(process.env.AWS_RUNTIME_BRIDGE_BASE_URL||"").trim();if(n)return n;const t=String(process.env.AWS_RUNTIME_BRIDGE_PORT||"").trim();if(t){const e=Number.parseInt(t,10);if(Number.isFinite(e)&&e>=1&&e<=65535)return`http://127.0.0.1:${e}`}const r=L();return r!==null?`http://127.0.0.1:${r}`:`http://127.0.0.1:${h}`}function P(n,t){for(const r of n.split(/\r?\n/)){const e=r.trim();if(!e||e.startsWith("#"))continue;const o=e.search(/[=:]/);if(o<=0||e.slice(0,o).trim()!==t)continue;return e.slice(o+1).trim()||void 0}}function m(){return String(process.env.AWS_TEST_HOME||"").trim()||String(process.env.AWS_RUNTIME_HOME_DIR||"").trim()||_.homedir()}function $(){return String(process.env.ACODE_HOME||"").trim()||g.join(m(),".acode")}function L(){const n=g.join(m(),".aws-bridge","bridge-runtime.properties");let t;try{t=d.readFileSync(n,"utf8")}catch{return null}const r=P(t,"port");if(r===void 0)return null;const e=Number.parseInt(r,10);return Number.isFinite(e)&&e>=1&&e<=65535?e:(s.warn("[Config] %s \u4E2D port \u65E0\u6548\uFF1A%s\uFF0C\u56DE\u9000\u9ED8\u8BA4\u7AEF\u53E3 %d",n,r,h),null)}function a(n){return n.replace(/^((?:https?|wss?):\/\/(?:\[[^\]]+\]|[^/:?#]+):\d+):\d+(?=\/|$)/i,"$1")}function p(n){const t=n.toLowerCase();return t==="localhost"||t==="::1"||t==="[::1]"?"127.0.0.1":t}function E(n){const t=String(n||"").trim();if(!t)return;const r=a(t);try{const e=new URL(r);return e.protocol==="ws:"?e.protocol="http:":e.protocol==="wss:"?e.protocol="https:":e.protocol=e.protocol.toLowerCase(),e.hostname=p(e.hostname),e.origin}catch{return r.replace(/\/+$/,"")}}function H(n){const t=String(n||"").trim();if(!t)return;const r=a(t);try{const e=new URL(r);return e.protocol==="http:"?e.protocol="ws:":e.protocol==="https:"?e.protocol="wss:":e.protocol!=="ws:"&&e.protocol!=="wss:"&&(e.protocol="ws:"),e.hostname=p(e.hostname),e.pathname=e.pathname.includes("/ws/agent")?e.pathname:`${e.pathname.replace(/\/$/,"")}/ws/agent`,e.search="",e.hash="",e.toString()}catch{const e=E(r);return e?`${e}/ws/agent`.replace(/^http:/,"ws:").replace(/^https:/,"wss:"):void 0}}function T(n){const t=String(n||"").trim();if(!t)return;const r=a(t);try{const e=new URL(r);return e.protocol==="ws:"?e.protocol="http:":e.protocol==="wss:"?e.protocol="https:":e.protocol!=="http:"&&e.protocol!=="https:"&&(e.protocol="http:"),e.hostname=p(e.hostname),e.pathname=e.pathname.includes("/mcp/call")?e.pathname:`${e.pathname.replace(/\/$/,"")}/mcp/call`,e.search="",e.hash="",e.toString()}catch{const e=E(r);return e?`${e}/mcp/call`:void 0}}class k{agentConfig=null;serverConfig=null;loadConfig(){const t=process.env.AWS_WORKSPACE_PATH||process.cwd();this.agentConfig={roleName:process.env.AWS_ROLE_NAME||"",agentId:process.env.AWS_AGENT_ID||void 0,projectId:process.env.AWS_PROJECT_NAME||"default",workspacePath:t,prompt:process.env.AWS_PROMPT||"",describe:""},s.info("[Config] MCP \u5BA2\u6237\u7AEF\u914D\u7F6E\u5DF2\u52A0\u8F7D"),s.info(`[Config] \u5DE5\u4F5C\u76EE\u5F55: ${this.agentConfig.workspacePath}`),s.info(`[Config] \u9879\u76EE\u540D: ${this.agentConfig.projectId}`),this.agentConfig.agentId&&s.info(`[Config] \u9884\u8BBE AgentID: ${this.agentConfig.agentId}`),this.serverConfig={serverUrl:process.env.AWS_SERVER_URL||A,heartbeatInterval:parseInt(process.env.AWS_HEARTBEAT_INTERVAL||String(C),10),heartbeatTimeout:parseInt(process.env.AWS_HEARTBEAT_TIMEOUT||String(R),10),reconnectInterval:v,reconnectMaxInterval:this.parsePositiveIntegerEnv("AWS_RECONNECT_MAX_INTERVAL",I),reconnectJitterMs:this.parsePositiveIntegerEnv("AWS_RECONNECT_JITTER_MS",S),maxReconnectAttempts:U,userId:process.env.AWS_USER_ID||void 0,runtimeBridgeBaseUrl:N(),mcpHttpUrl:process.env.AWS_MCP_HTTP_URL||void 0,runtimeTokenRefreshIntervalMs:this.parseRuntimeTokenRefreshInterval(),mcpHttpRequestTimeoutMs:this.parsePositiveIntegerEnv("AWS_MCP_HTTP_REQUEST_TIMEOUT_MS",M)},s.info(`[Config] \u670D\u52A1\u5668\u5730\u5740: ${this.serverConfig.serverUrl}`),s.info(`[Config] Runtime Bridge \u5730\u5740: ${this.serverConfig.runtimeBridgeBaseUrl}`)}applyRuntimeLaunchBinding(t){if(!this.agentConfig||!this.serverConfig)throw new Error("Configuration is not loaded. Call loadConfig() first.");const r=String(t.agentId||"").trim();r&&(this.agentConfig.agentId=r);const e=String(t.workspacePath||"").trim();e&&(this.agentConfig.workspacePath=e);const o=String(t.runtimeAccessToken||"").trim();o&&(this.serverConfig.runtimeAccessToken=o);const i=String(t.runtimeAccessTokenExpiresAt||"").trim();i&&(this.serverConfig.runtimeAccessTokenExpiresAt=i);const c=String(t.userId||"").trim();c&&(this.serverConfig.userId=c);const l=H(t.serverUrl||t.schedulerBaseUrl);l&&(this.serverConfig.serverUrl=l);const f=T(t.mcpHttpUrl);if(f)this.serverConfig.mcpHttpUrl=f;else if(t.schedulerBaseUrl){const u=T(t.schedulerBaseUrl);u&&(this.serverConfig.mcpHttpUrl=u)}s.info(`[Config] \u5DF2\u5E94\u7528 runtime-bridge \u542F\u52A8\u7ED1\u5B9A: agentId=${this.agentConfig.agentId||"(\u7A7A)"}`)}parseRuntimeTokenRefreshInterval(){return this.parsePositiveIntegerEnv("AWS_RUNTIME_TOKEN_REFRESH_INTERVAL_MS",w)}parsePositiveIntegerEnv(t,r){const e=process.env[t];if(!e||!e.trim())return r;const o=Number.parseInt(e,10);return!Number.isFinite(o)||o<=0?(s.warn(`[Config] ${t} \u65E0\u6548\uFF0C\u4F7F\u7528\u9ED8\u8BA4\u503C ${r}ms`),r):o}updateRuntimeAccessToken(t){if(!this.serverConfig)return!1;const r=t.trim();return!r||r===this.serverConfig.runtimeAccessToken?!1:(this.serverConfig.runtimeAccessToken=r,s.info("[Config] runtimeAccessToken \u5DF2\u66F4\u65B0\uFF08401 \u6062\u590D\uFF09"),!0)}getAgentConfig(){if(!this.agentConfig)throw new Error("Configuration is not loaded. Call loadConfig() first.");return this.agentConfig}getServerConfig(){if(!this.serverConfig)throw new Error("Configuration is not loaded. Call loadConfig() first.");return this.serverConfig}}const F=new k;export{k as ConfigManager,F as configManager,$ as resolveAcodeDataHome};
|
|
2
2
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
3
|
-
import { ConfigManager } from './config.js';
|
|
6
|
+
import { ConfigManager, resolveAcodeDataHome } from './config.js';
|
|
4
7
|
describe('ConfigManager', () => {
|
|
5
8
|
const originalEnv = { ...process.env };
|
|
6
9
|
let configManager;
|
|
@@ -97,6 +100,73 @@ describe('ConfigManager', () => {
|
|
|
97
100
|
assert.equal(serverConfig.mcpHttpRequestTimeoutMs, 30000);
|
|
98
101
|
});
|
|
99
102
|
});
|
|
103
|
+
describe('runtimeBridgeBaseUrl discovery', () => {
|
|
104
|
+
let tempHome;
|
|
105
|
+
const createdRoots = [];
|
|
106
|
+
function useTempHome() {
|
|
107
|
+
tempHome = mkdtempSync(path.join(os.tmpdir(), 'aws-mcp-home-'));
|
|
108
|
+
createdRoots.push(tempHome);
|
|
109
|
+
// 主目录解析优先读 AWS_TEST_HOME(JS 层读取,不受外层 afterEach
|
|
110
|
+
// 整体替换 process.env 后与真实 OS 环境断开的影响)
|
|
111
|
+
process.env.AWS_TEST_HOME = tempHome;
|
|
112
|
+
delete process.env.AWS_RUNTIME_HOME_DIR;
|
|
113
|
+
delete process.env.AWS_RUNTIME_BRIDGE_BASE_URL;
|
|
114
|
+
}
|
|
115
|
+
function writeBridgeRuntimeProperties(content) {
|
|
116
|
+
const dir = path.join(tempHome, '.aws-bridge');
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
writeFileSync(path.join(dir, 'bridge-runtime.properties'), content, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
for (const root of createdRoots.splice(0)) {
|
|
122
|
+
rmSync(root, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
it('falls back to default bridge port when env and properties file are absent', () => {
|
|
126
|
+
useTempHome();
|
|
127
|
+
configManager.loadConfig();
|
|
128
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:18081');
|
|
129
|
+
});
|
|
130
|
+
it('discovers bridge port from bridge-runtime.properties', () => {
|
|
131
|
+
useTempHome();
|
|
132
|
+
writeBridgeRuntimeProperties('port=28081\npid=4321\nstartedAt=2026-08-26T00:00:00.000Z\n');
|
|
133
|
+
configManager.loadConfig();
|
|
134
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:28081');
|
|
135
|
+
});
|
|
136
|
+
it('accepts colon-separated properties entries', () => {
|
|
137
|
+
useTempHome();
|
|
138
|
+
writeBridgeRuntimeProperties('port: 28100\n');
|
|
139
|
+
configManager.loadConfig();
|
|
140
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:28100');
|
|
141
|
+
});
|
|
142
|
+
it('falls back to default when port entry is invalid', () => {
|
|
143
|
+
useTempHome();
|
|
144
|
+
writeBridgeRuntimeProperties('port=not-a-number\n');
|
|
145
|
+
configManager.loadConfig();
|
|
146
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:18081');
|
|
147
|
+
});
|
|
148
|
+
it('prefers explicit AWS_RUNTIME_BRIDGE_BASE_URL over the properties file', () => {
|
|
149
|
+
useTempHome();
|
|
150
|
+
writeBridgeRuntimeProperties('port=28081\n');
|
|
151
|
+
process.env.AWS_RUNTIME_BRIDGE_BASE_URL = 'http://127.0.0.1:29099';
|
|
152
|
+
configManager.loadConfig();
|
|
153
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:29099');
|
|
154
|
+
});
|
|
155
|
+
it('prefers inherited AWS_RUNTIME_BRIDGE_PORT over the properties file', () => {
|
|
156
|
+
useTempHome();
|
|
157
|
+
// 文件来自最后一次落盘的实例(如生产 18081),继承端口代表本次拉起自己的实例(如 dev 28081)
|
|
158
|
+
writeBridgeRuntimeProperties('port=18081\n');
|
|
159
|
+
process.env.AWS_RUNTIME_BRIDGE_PORT = '28081';
|
|
160
|
+
configManager.loadConfig();
|
|
161
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:28081');
|
|
162
|
+
});
|
|
163
|
+
it('ignores invalid inherited AWS_RUNTIME_BRIDGE_PORT and falls back to default', () => {
|
|
164
|
+
useTempHome();
|
|
165
|
+
process.env.AWS_RUNTIME_BRIDGE_PORT = 'not-a-port';
|
|
166
|
+
configManager.loadConfig();
|
|
167
|
+
assert.equal(configManager.getServerConfig().runtimeBridgeBaseUrl, 'http://127.0.0.1:18081');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
100
170
|
describe('getAgentConfig', () => {
|
|
101
171
|
it('throws error when config not loaded', () => {
|
|
102
172
|
assert.throws(() => configManager.getAgentConfig(), /Configuration is not loaded/);
|
|
@@ -142,6 +212,16 @@ describe('ConfigManager', () => {
|
|
|
142
212
|
assert.equal(serverConfig.serverUrl, 'ws://127.0.0.1:8080/ws/agent');
|
|
143
213
|
assert.equal(serverConfig.mcpHttpUrl, 'http://127.0.0.1:8080/mcp/call');
|
|
144
214
|
});
|
|
215
|
+
it('keeps handed-down loopback dev proxy port without rewriting', () => {
|
|
216
|
+
configManager.loadConfig();
|
|
217
|
+
configManager.applyRuntimeLaunchBinding({
|
|
218
|
+
agentId: 'agent-a',
|
|
219
|
+
schedulerBaseUrl: 'http://localhost:3000',
|
|
220
|
+
});
|
|
221
|
+
const serverConfig = configManager.getServerConfig();
|
|
222
|
+
assert.equal(serverConfig.serverUrl, 'ws://127.0.0.1:3000/ws/agent');
|
|
223
|
+
assert.equal(serverConfig.mcpHttpUrl, 'http://127.0.0.1:3000/mcp/call');
|
|
224
|
+
});
|
|
145
225
|
it('preserves MCP HTTP subpaths from runtime bridge bindings', () => {
|
|
146
226
|
configManager.loadConfig();
|
|
147
227
|
configManager.applyRuntimeLaunchBinding({
|
|
@@ -165,3 +245,15 @@ describe('ConfigManager', () => {
|
|
|
165
245
|
});
|
|
166
246
|
});
|
|
167
247
|
});
|
|
248
|
+
describe('resolveAcodeDataHome', () => {
|
|
249
|
+
it('prefers explicit ACODE_HOME over the resolved home dir', () => {
|
|
250
|
+
process.env.ACODE_HOME = '/tmp/isolated-acode';
|
|
251
|
+
assert.equal(resolveAcodeDataHome(), '/tmp/isolated-acode');
|
|
252
|
+
});
|
|
253
|
+
it('falls back to {home}/.acode based on AWS_TEST_HOME', () => {
|
|
254
|
+
delete process.env.ACODE_HOME;
|
|
255
|
+
delete process.env.AWS_RUNTIME_HOME_DIR;
|
|
256
|
+
process.env.AWS_TEST_HOME = path.join(os.tmpdir(), 'fake-home-x');
|
|
257
|
+
assert.equal(resolveAcodeDataHome(), path.join(process.env.AWS_TEST_HOME, '.acode'));
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -16,6 +16,8 @@ export declare class HttpClient {
|
|
|
16
16
|
fetchRuntimeFeatures(): Promise<Record<string, boolean>>;
|
|
17
17
|
|
|
18
18
|
callToolOnce<T = unknown>(toolName: string, args: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
19
|
+
private readTextWithTimeout;
|
|
20
|
+
private parseJsonWithTimeout;
|
|
19
21
|
|
|
20
22
|
private singleFetchWithTimeout;
|
|
21
23
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{logger as a}from"./logger.js";const d=12e4,w=5e3,g=3e4,m=1e3;function h(c){const t=c.trim();return t?t.length<=m?t:`${t.slice(0,m)}...(truncated)`:""}function T(c){return c.replace(/^((?:https?|wss?):\/\/(?:\[[^\]]+\]|[^/:?#]+):\d+):\d+(?=\/|$)/i,"$1")}function y(c){const t=c.toLowerCase();return t==="localhost"||t==="::1"||t==="[::1]"?"127.0.0.1":t}function p(c,t={}){const r=String(c||"").trim();if(!r)return;const n=T(r);try{const e=new URL(n);e.protocol==="ws:"?e.protocol="http:":e.protocol==="wss:"?e.protocol="https:":e.protocol=e.protocol.toLowerCase(),e.hostname=y(e.hostname);const s=t.fromAgentWebSocketUrl?e.pathname.replace(/\/ws\/agent\/?$/,""):e.pathname;return e.pathname=s.includes("/mcp/call")?s:`${s.replace(/\/$/,"")}/mcp/call`,e.search="",e.hash="",e.toString()}catch{const e=(t.fromAgentWebSocketUrl?n.replace(/\/ws\/agent\/?$/i,""):n).replace(/\/+$/,"");return e.endsWith("/mcp/call")?e:`${e}/mcp/call`}}const C=6e5;class k{serverConfig;mcpHttpUrl;tokenRefreshPromise=null;constructor(t){this.serverConfig=t,this.mcpHttpUrl=this.resolveMcpHttpUrl()}async callTool(t,r){await this.ensureRuntimeAccessToken();const n=Date.now()+this.resolveRetryMaxAccumulatedMs();let e=0;for(;;){e++;let s;try{s=await this.executeToolRequest(t,r)}catch(o){if(Date.now()<n&&this.isTransientError(o)){await this.delayForRetry(n,t,e);continue}throw a.warn("[HttpClient] MCP tool %s \u8BF7\u6C42\u4E0D\u53EF\u6062\u590D\u5F02\u5E38 (attempt=%s): %s",t,e,o instanceof Error?o.message:String(o)),o}if(s.status===401){if(a.warn(`[HttpClient] MCP Tool ${t} \u8FD4\u56DE 401\uFF0C\u5C1D\u8BD5\u5411 bridge \u5237\u65B0 token`),await this.tryRefreshToken(r)){let u;try{u=await this.executeToolRequest(t,r)}catch(l){if(Date.now()<n&&this.isTransientError(l)){await this.delayForRetry(n,t,e);continue}throw a.warn("[HttpClient] MCP tool %s 401\u91CD\u8BD5\u5F02\u5E38 (attempt=%s): %s",t,e,l instanceof Error?l.message:String(l)),l}if(!u.ok){if(Date.now()<n&&this.isRetryableHttpStatus(u.status)){await this.delayForRetry(n,t,e);continue}const l=typeof u.text=="function"?h(await u.text().catch(()=>"")):"";throw new Error(`MCP tool ${t} call failed: ${u.status} ${u.statusText}${l?` - ${l}`:""}`)}const f=await u.json();if(typeof f.error=="string"&&f.error.trim().length>0)throw new Error(`MCP tool ${t} failed: ${f.error}`);return f}a.warn("[HttpClient] token \u5237\u65B0\u5931\u8D25\u6216\u65E0\u65B0 token\uFF0C\u8FD4\u56DE\u539F\u59CB 401 \u9519\u8BEF")}if(!s.ok){if(Date.now()<n&&this.isRetryableHttpStatus(s.status)){await this.delayForRetry(n,t,e);continue}const o=typeof s.text=="function"?h(await s.text().catch(()=>"")):"";throw new Error(`MCP tool ${t} call failed: ${s.status} ${s.statusText}${o?` - ${o}`:""}`)}const i=await s.json();if(typeof i.error=="string"&&i.error.trim().length>0)throw new Error(`MCP tool ${t} failed: ${i.error}`);return i}}async fetchRuntimeFeatures(){await this.ensureRuntimeAccessToken();const t=this.mcpHttpUrl.replace(/\/mcp\/call(\/)?$/,"/mcp/features$1");let r;try{r=await fetch(t,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({})})}catch(e){throw a.warn("[HttpClient] \u67E5\u8BE2\u80FD\u529B\u5F00\u5173\u8BF7\u6C42\u5931\u8D25: %s",e instanceof Error?e.message:String(e)),new Error(`fetch runtime features failed: ${e instanceof Error?e.message:String(e)}`)}if(!r.ok)throw a.warn("[HttpClient] \u67E5\u8BE2\u80FD\u529B\u5F00\u5173\u5931\u8D25: status=%s, statusText=%s",r.status,r.statusText),new Error(`fetch runtime features failed: ${r.status} ${r.statusText}`);const n=await r.json();return!n||typeof n.features!="object"||n.features===null?{}:n.features}async callToolOnce(t,r,n=C){await this.ensureRuntimeAccessToken();const e=o=>fetch(this.mcpHttpUrl,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({tool:t,arguments:r}),signal:o});let s=await this.singleFetchWithTimeout(e,n,t);if(s.status===401&&(a.warn(`[HttpClient] MCP Tool ${t} \u8FD4\u56DE 401\uFF0C\u5C1D\u8BD5\u5411 bridge \u5237\u65B0 token`),await this.tryRefreshToken(r)&&(s=await this.singleFetchWithTimeout(e,n,t))),!s.ok){const o=typeof s.text=="function"?h(await s.text().catch(()=>"")):"";throw new Error(`MCP tool ${t} call failed: ${s.status} ${s.statusText}${o?` - ${o}`:""}`)}const i=await s.json();if(typeof i.error=="string"&&i.error.trim().length>0)throw new Error(`MCP tool ${t} failed: ${i.error}`);return i}async singleFetchWithTimeout(t,r,n){const e=new AbortController;let s;r>0&&(s=setTimeout(()=>e.abort(),r));try{return await t(e.signal)}catch(i){throw e.signal.aborted?new Error(`MCP tool ${n} timed out after ${r}ms`):i}finally{s!==void 0&&clearTimeout(s)}}async executeToolRequest(t,r){return await this.fetchWithTimeout(this.mcpHttpUrl,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({tool:t,arguments:r})},`MCP tool ${t}`)}async fetchWithTimeout(t,r,n){const e=this.resolveRequestTimeoutMs(),s=new AbortController,i=setTimeout(()=>s.abort(),e);try{return await fetch(t,{...r,signal:s.signal})}catch(o){throw s.signal.aborted?new Error(`${n} timed out after ${e}ms`):o}finally{clearTimeout(i)}}resolveRequestTimeoutMs(){const t=this.serverConfig.mcpHttpRequestTimeoutMs;return typeof t=="number"&&Number.isFinite(t)&&t>0?t:d}resolveRetryIntervalMs(){const t=this.serverConfig.mcpRetryIntervalMs;return typeof t=="number"&&Number.isFinite(t)&&t>=0?t:w}resolveRetryMaxAccumulatedMs(){const t=this.serverConfig.mcpRetryMaxAccumulatedMs;return typeof t=="number"&&Number.isFinite(t)&&t>=0?t:g}isTransientError(t){if(!(t instanceof Error))return!1;const r=t.message;return!!(/timed out after \d+ms/.test(r)||/fetch failed/i.test(r)||/network/i.test(r)||/ECONNREFUSED/i.test(r)||/ECONNRESET/i.test(r))}isRetryableHttpStatus(t){return t>=500||t===429}async delayForRetry(t,r,n){const e=t-Date.now(),s=Math.min(this.resolveRetryIntervalMs(),Math.max(0,e));a.warn(`[HttpClient] MCP tool ${r} \u8BF7\u6C42\u5931\u8D25\uFF0C${s}ms \u540E\u91CD\u8BD5 (\u7B2C${n}\u6B21)`),s>0&&await new Promise(i=>setTimeout(i,s))}buildRequestHeaders(){const t={"Content-Type":"application/json"};return this.serverConfig.runtimeAccessToken&&(t["X-Runtime-Token"]=this.serverConfig.runtimeAccessToken,t.Authorization=`Bearer ${this.serverConfig.runtimeAccessToken}`),t}async ensureRuntimeAccessToken(){if(!(this.serverConfig.runtimeAccessToken||!this.serverConfig.runtimeBridgeBaseUrl))throw new Error("runtimeAccessToken is required; MCP nodes must claim a session token from launch binding")}async tryRefreshToken(t){if(this.tokenRefreshPromise)return this.tokenRefreshPromise;this.tokenRefreshPromise=this.doRefreshToken(t);try{return await this.tokenRefreshPromise}finally{this.tokenRefreshPromise=null}}async doRefreshToken(t){const r=this.serverConfig.runtimeBridgeBaseUrl;if(!r)return a.warn("[HttpClient] \u65E0 runtimeBridgeBaseUrl\uFF0C\u65E0\u6CD5\u5237\u65B0 token"),!1;const n=String(t?.agentId||"").trim();if(!n)return a.warn("[HttpClient] \u65E0 agentId\uFF0C\u65E0\u6CD5\u5237\u65B0 token"),!1;try{const e=await fetch(`${r}/runtime/binding/refresh-token`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({agentId:n,currentToken:this.serverConfig.runtimeAccessToken||""})});if(!e.ok)return a.warn(`[HttpClient] bridge refresh-token \u8FD4\u56DE ${e.status}\uFF0C\u65E0\u6CD5\u5237\u65B0`),!1;const i=(await e.json()).runtimeAccessToken;return i?i===this.serverConfig.runtimeAccessToken?!1:(this.serverConfig.runtimeAccessToken=i,a.info("[HttpClient] runtimeAccessToken \u5DF2\u901A\u8FC7 bridge refresh \u66F4\u65B0"),!0):(a.warn("[HttpClient] bridge \u672A\u8FD4\u56DE\u65B0 token"),!1)}catch(e){return a.warn("[HttpClient] \u5237\u65B0 token \u5931\u8D25: %s",e instanceof Error?e.message:String(e)),e instanceof Error&&e.stack&&a.warn("[HttpClient] \u5237\u65B0 token \u5F02\u5E38\u5806\u6808:",e.stack),!1}}async refreshRuntimeAccessToken(t){return this.tryRefreshToken({agentId:t})}resolveMcpHttpUrl(){const t=this.serverConfig.mcpHttpUrl;if(t&&t.trim().length>0){const n=p(t);if(n)return n}const r=p(this.serverConfig.serverUrl,{fromAgentWebSocketUrl:!0});return r||this.serverConfig.serverUrl}getMcpHttpUrl(){return this.mcpHttpUrl}}export{k as HttpClient};
|
|
1
|
+
import{logger as o}from"./logger.js";import{meetingDebugLog as h}from"./meeting-debug-log.js";const w=12e4,g=5e3,T=3e4,d=1e3;function p(l){const e=l.trim();return e?e.length<=d?e:`${e.slice(0,d)}...(truncated)`:""}function y(l){return l.replace(/^((?:https?|wss?):\/\/(?:\[[^\]]+\]|[^/:?#]+):\d+):\d+(?=\/|$)/i,"$1")}function C(l){const e=l.toLowerCase();return e==="localhost"||e==="::1"||e==="[::1]"?"127.0.0.1":e}function m(l,e={}){const r=String(l||"").trim();if(!r)return;const s=y(r);try{const t=new URL(s);t.protocol==="ws:"?t.protocol="http:":t.protocol==="wss:"?t.protocol="https:":t.protocol=t.protocol.toLowerCase(),t.hostname=C(t.hostname);const n=e.fromAgentWebSocketUrl?t.pathname.replace(/\/ws\/agent\/?$/,""):t.pathname;return t.pathname=n.includes("/mcp/call")?n:`${n.replace(/\/$/,"")}/mcp/call`,t.search="",t.hash="",t.toString()}catch{const t=(e.fromAgentWebSocketUrl?s.replace(/\/ws\/agent\/?$/i,""):s).replace(/\/+$/,"");return t.endsWith("/mcp/call")?t:`${t}/mcp/call`}}const R=6e5;class E{serverConfig;mcpHttpUrl;tokenRefreshPromise=null;constructor(e){this.serverConfig=e,this.mcpHttpUrl=this.resolveMcpHttpUrl()}async callTool(e,r){await this.ensureRuntimeAccessToken();const s=Date.now()+this.resolveRetryMaxAccumulatedMs();let t=0;for(;;){t++;let n;try{n=await this.executeToolRequest(e,r)}catch(a){if(Date.now()<s&&this.isTransientError(a)){await this.delayForRetry(s,e,t);continue}throw o.warn("[HttpClient] MCP tool %s \u8BF7\u6C42\u4E0D\u53EF\u6062\u590D\u5F02\u5E38 (attempt=%s): %s",e,t,a instanceof Error?a.message:String(a)),a}if(n.status===401){if(o.warn(`[HttpClient] MCP Tool ${e} \u8FD4\u56DE 401\uFF0C\u5C1D\u8BD5\u5411 bridge \u5237\u65B0 token`),await this.tryRefreshToken(r)){let c;try{c=await this.executeToolRequest(e,r)}catch(f){if(Date.now()<s&&this.isTransientError(f)){await this.delayForRetry(s,e,t);continue}throw o.warn("[HttpClient] MCP tool %s 401\u91CD\u8BD5\u5F02\u5E38 (attempt=%s): %s",e,t,f instanceof Error?f.message:String(f)),f}if(!c.ok){if(Date.now()<s&&this.isRetryableHttpStatus(c.status)){await this.delayForRetry(s,e,t);continue}const f=typeof c.text=="function"?p(await c.text().catch(()=>"")):"";throw new Error(`MCP tool ${e} call failed: ${c.status} ${c.statusText}${f?` - ${f}`:""}`)}const u=await c.json();if(typeof u.error=="string"&&u.error.trim().length>0)throw new Error(`MCP tool ${e} failed: ${u.error}`);return u}o.warn("[HttpClient] token \u5237\u65B0\u5931\u8D25\u6216\u65E0\u65B0 token\uFF0C\u8FD4\u56DE\u539F\u59CB 401 \u9519\u8BEF")}if(!n.ok){if(Date.now()<s&&this.isRetryableHttpStatus(n.status)){await this.delayForRetry(s,e,t);continue}const a=typeof n.text=="function"?p(await n.text().catch(()=>"")):"";throw new Error(`MCP tool ${e} call failed: ${n.status} ${n.statusText}${a?` - ${a}`:""}`)}const i=await n.json();if(typeof i.error=="string"&&i.error.trim().length>0)throw new Error(`MCP tool ${e} failed: ${i.error}`);return i}}async fetchRuntimeFeatures(){await this.ensureRuntimeAccessToken();const e=this.mcpHttpUrl.replace(/\/mcp\/call(\/)?$/,"/mcp/features$1");let r;try{r=await fetch(e,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({})})}catch(t){throw o.warn("[HttpClient] \u67E5\u8BE2\u80FD\u529B\u5F00\u5173\u8BF7\u6C42\u5931\u8D25: %s",t instanceof Error?t.message:String(t)),new Error(`fetch runtime features failed: ${t instanceof Error?t.message:String(t)}`)}if(!r.ok)throw o.warn("[HttpClient] \u67E5\u8BE2\u80FD\u529B\u5F00\u5173\u5931\u8D25: status=%s, statusText=%s",r.status,r.statusText),new Error(`fetch runtime features failed: ${r.status} ${r.statusText}`);const s=await r.json();return!s||typeof s.features!="object"||s.features===null?{}:s.features}async callToolOnce(e,r,s=R){await this.ensureRuntimeAccessToken(),o.info("[HttpClient] callToolOnce \u5F00\u59CB: tool=%s, timeoutMs=%d, url=%s, agentId=%s",e,s,this.mcpHttpUrl,String(r?.agentId??"")),e==="poll_message"&&h("http.callToolOnce.start",`tool=${e} timeoutMs=${s} preemptiveJoinWaitMs=${String(r?.preemptiveJoinWaitMs??"-")}`);const t=c=>fetch(this.mcpHttpUrl,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({tool:e,arguments:r}),signal:c}),n=Date.now();let i=await this.singleFetchWithTimeout(t,s,e);if(o.info("[HttpClient] callToolOnce \u6536\u5230\u54CD\u5E94: tool=%s, status=%d, \u8017\u65F6=%dms",e,i.status,Date.now()-n),e==="poll_message"&&h("http.callToolOnce.response",`status=${i.status} elapsedMs=${Date.now()-n}`),i.status===401&&(o.warn(`[HttpClient] MCP Tool ${e} \u8FD4\u56DE 401\uFF0C\u5C1D\u8BD5\u5411 bridge \u5237\u65B0 token`),await this.tryRefreshToken(r)&&(i=await this.singleFetchWithTimeout(t,s,e),o.info("[HttpClient] callToolOnce token \u5237\u65B0\u540E\u91CD\u8BD5\u6536\u5230\u54CD\u5E94: tool=%s, status=%d, \u7D2F\u8BA1\u8017\u65F6=%dms",e,i.status,Date.now()-n))),!i.ok){const c=typeof i.text=="function"?p(await this.readTextWithTimeout(i,s,e).catch(()=>"")):"";throw o.info("[HttpClient] callToolOnce \u975E 2xx \u54CD\u5E94: tool=%s, status=%d, body=%s",e,i.status,c||"(\u7A7A)"),new Error(`MCP tool ${e} call failed: ${i.status} ${i.statusText}${c?` - ${c}`:""}`)}const a=await this.parseJsonWithTimeout(i,s,e);if(o.info("[HttpClient] callToolOnce \u5B8C\u6210: tool=%s, \u603B\u8017\u65F6=%dms, error=%s",e,Date.now()-n,typeof a.error=="string"?a.error:"(\u65E0)"),e==="poll_message"){const c=Array.isArray(a.messages)?a.messages:[];h("http.callToolOnce.parsed",`totalElapsedMs=${Date.now()-n} serverMessages=${c.length} `+JSON.stringify(c.map(u=>({msgId:u.msgId,channel:u.channel,sender:u.senderId}))))}if(typeof a.error=="string"&&a.error.trim().length>0)throw new Error(`MCP tool ${e} failed: ${a.error}`);return a}async readTextWithTimeout(e,r,s){if(r<=0)return await e.text();let t;try{return await Promise.race([e.text(),new Promise((n,i)=>{t=setTimeout(()=>i(new Error(`MCP tool ${s} timed out after ${r}ms`)),r)})])}finally{t!==void 0&&clearTimeout(t)}}async parseJsonWithTimeout(e,r,s){if(r<=0)return await e.json();let t;try{return await Promise.race([e.json(),new Promise((n,i)=>{t=setTimeout(()=>i(new Error(`MCP tool ${s} timed out after ${r}ms`)),r)})])}finally{t!==void 0&&clearTimeout(t)}}async singleFetchWithTimeout(e,r,s){const t=new AbortController;let n;r>0&&(n=setTimeout(()=>t.abort(),r));try{return await e(t.signal)}catch(i){throw t.signal.aborted?(s==="poll_message"&&h("http.callToolOnce.timeout",`timeoutMs=${r} \u670D\u52A1\u7AEF\u53EF\u80FD\u4ECD\u5728\u6267\u884C\u540C\u4E00\u8BF7\u6C42\uFF08Callable \u5360\u7EBF\u81F3\u9884\u7B97\u7ED3\u675F\uFF09`),new Error(`MCP tool ${s} timed out after ${r}ms`)):i}finally{n!==void 0&&clearTimeout(n)}}async executeToolRequest(e,r){return await this.fetchWithTimeout(this.mcpHttpUrl,{method:"POST",headers:this.buildRequestHeaders(),body:JSON.stringify({tool:e,arguments:r})},`MCP tool ${e}`)}async fetchWithTimeout(e,r,s){const t=this.resolveRequestTimeoutMs(),n=new AbortController,i=setTimeout(()=>n.abort(),t);try{return await fetch(e,{...r,signal:n.signal})}catch(a){throw n.signal.aborted?new Error(`${s} timed out after ${t}ms`):a}finally{clearTimeout(i)}}resolveRequestTimeoutMs(){const e=this.serverConfig.mcpHttpRequestTimeoutMs;return typeof e=="number"&&Number.isFinite(e)&&e>0?e:w}resolveRetryIntervalMs(){const e=this.serverConfig.mcpRetryIntervalMs;return typeof e=="number"&&Number.isFinite(e)&&e>=0?e:g}resolveRetryMaxAccumulatedMs(){const e=this.serverConfig.mcpRetryMaxAccumulatedMs;return typeof e=="number"&&Number.isFinite(e)&&e>=0?e:T}isTransientError(e){if(!(e instanceof Error))return!1;const r=e.message;return!!(/timed out after \d+ms/.test(r)||/fetch failed/i.test(r)||/network/i.test(r)||/ECONNREFUSED/i.test(r)||/ECONNRESET/i.test(r))}isRetryableHttpStatus(e){return e>=500||e===429}async delayForRetry(e,r,s){const t=e-Date.now(),n=Math.min(this.resolveRetryIntervalMs(),Math.max(0,t));o.warn(`[HttpClient] MCP tool ${r} \u8BF7\u6C42\u5931\u8D25\uFF0C${n}ms \u540E\u91CD\u8BD5 (\u7B2C${s}\u6B21)`),n>0&&await new Promise(i=>setTimeout(i,n))}buildRequestHeaders(){const e={"Content-Type":"application/json"};return this.serverConfig.runtimeAccessToken&&(e["X-Runtime-Token"]=this.serverConfig.runtimeAccessToken,e.Authorization=`Bearer ${this.serverConfig.runtimeAccessToken}`),e}async ensureRuntimeAccessToken(){if(this.serverConfig.runtimeAccessToken||!this.serverConfig.runtimeBridgeBaseUrl){o.info("[HttpClient] ensureRuntimeAccessToken: tokenLen=%d, bridgeBaseUrl=%s",(this.serverConfig.runtimeAccessToken||"").length,this.serverConfig.runtimeBridgeBaseUrl||"(\u7A7A)");return}throw new Error("runtimeAccessToken is required; MCP nodes must claim a session token from launch binding")}async tryRefreshToken(e){if(this.tokenRefreshPromise)return this.tokenRefreshPromise;this.tokenRefreshPromise=this.doRefreshToken(e);try{return await this.tokenRefreshPromise}finally{this.tokenRefreshPromise=null}}async doRefreshToken(e){const r=this.serverConfig.runtimeBridgeBaseUrl;if(!r)return o.warn("[HttpClient] \u65E0 runtimeBridgeBaseUrl\uFF0C\u65E0\u6CD5\u5237\u65B0 token"),!1;const s=String(e?.agentId||"").trim();if(!s)return o.warn("[HttpClient] \u65E0 agentId\uFF0C\u65E0\u6CD5\u5237\u65B0 token"),!1;try{const t=await fetch(`${r}/runtime/binding/refresh-token`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({agentId:s,currentToken:this.serverConfig.runtimeAccessToken||""})});if(!t.ok)return o.warn(`[HttpClient] bridge refresh-token \u8FD4\u56DE ${t.status}\uFF0C\u65E0\u6CD5\u5237\u65B0`),!1;const i=(await t.json()).runtimeAccessToken;return i?i===this.serverConfig.runtimeAccessToken?!1:(this.serverConfig.runtimeAccessToken=i,o.info("[HttpClient] runtimeAccessToken \u5DF2\u901A\u8FC7 bridge refresh \u66F4\u65B0"),!0):(o.warn("[HttpClient] bridge \u672A\u8FD4\u56DE\u65B0 token"),!1)}catch(t){return o.warn("[HttpClient] \u5237\u65B0 token \u5931\u8D25: %s",t instanceof Error?t.message:String(t)),t instanceof Error&&t.stack&&o.warn("[HttpClient] \u5237\u65B0 token \u5F02\u5E38\u5806\u6808:",t.stack),!1}}async refreshRuntimeAccessToken(e){return this.tryRefreshToken({agentId:e})}resolveMcpHttpUrl(){const e=this.serverConfig.mcpHttpUrl;if(e&&e.trim().length>0){const s=m(e);if(s)return s}const r=m(this.serverConfig.serverUrl,{fromAgentWebSocketUrl:!0});return r||this.serverConfig.serverUrl}getMcpHttpUrl(){return this.mcpHttpUrl}}export{E as HttpClient};
|
|
2
2
|
|
|
@@ -6,7 +6,7 @@ import { afterEach, describe, it } from "node:test";
|
|
|
6
6
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
7
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
8
8
|
import { McpServer } from "./mcp-server.js";
|
|
9
|
-
import { MCP_TOOLS, LAUNCH_CONFIG_TOOL_NAMES } from "./mcp-tools.js";
|
|
9
|
+
import { MCP_TOOLS, LAUNCH_CONFIG_TOOL_NAMES, HIRE_TOOL_NAMES } from "./mcp-tools.js";
|
|
10
10
|
import { MessageBuffer } from "./message-buffer.js";
|
|
11
11
|
import { MemoryStore } from "./memory-store.js";
|
|
12
12
|
const LAUNCH_ITEM_FEATURE_KEY = "agentSys.launchItem";
|
|
@@ -57,7 +57,11 @@ describe("launch_config_* 能力开关门控(aws-mcp 侧)", () => {
|
|
|
57
57
|
for (const launchTool of LAUNCH_TOOL_NAMES) {
|
|
58
58
|
assert.equal(names.includes(launchTool), false, `${launchTool} 不应暴露`);
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// 能力开关 stub 返回空对象:启动项管理与招聘工具(默认关闭)均不暴露
|
|
61
|
+
for (const hireTool of HIRE_TOOL_NAMES) {
|
|
62
|
+
assert.equal(names.includes(hireTool), false, `${hireTool} 不应暴露`);
|
|
63
|
+
}
|
|
64
|
+
assert.equal(names.length, MCP_TOOLS.length - LAUNCH_TOOL_NAMES.length - HIRE_TOOL_NAMES.size);
|
|
61
65
|
});
|
|
62
66
|
it("开启「启动项管理」后暴露全部 9 个启动项工具", async () => {
|
|
63
67
|
const { client, dir } = await buildServer(async () => ({
|
|
@@ -32,6 +32,10 @@ export declare class McpServer {
|
|
|
32
32
|
|
|
33
33
|
private static readonly LAUNCH_TOOLS_ENV_KEY;
|
|
34
34
|
|
|
35
|
+
private static readonly HIRE_FEATURE_KEY;
|
|
36
|
+
|
|
37
|
+
private static readonly HIRE_TOOLS_ENV_KEY;
|
|
38
|
+
|
|
35
39
|
private leaseRenewalTimer;
|
|
36
40
|
private activeLeaseUris;
|
|
37
41
|
private static readonly LEASE_DURATION_MINUTES;
|
|
@@ -39,6 +43,18 @@ export declare class McpServer {
|
|
|
39
43
|
|
|
40
44
|
private profileSynced;
|
|
41
45
|
|
|
46
|
+
private urgentMeetingHydrateRequested;
|
|
47
|
+
private static readonly MEETING_HYDRATE_RETRY_COUNT;
|
|
48
|
+
private static readonly MEETING_HYDRATE_RETRY_DELAY_MS;
|
|
49
|
+
|
|
50
|
+
private static readonly MEETING_HYDRATE_TIMEOUT_REARM_DELAY_MS;
|
|
51
|
+
|
|
52
|
+
private meetingHydrateTimeoutRearmDelayMs;
|
|
53
|
+
|
|
54
|
+
private static readonly MEETING_HYDRATE_POLL_TIMEOUT_MS;
|
|
55
|
+
|
|
56
|
+
private static readonly MEETING_PREEMPTIVE_JOIN_WAIT_MS;
|
|
57
|
+
|
|
42
58
|
private pendingPollMessage;
|
|
43
59
|
|
|
44
60
|
private syncRoleMemoryStore;
|
|
@@ -68,6 +84,8 @@ export declare class McpServer {
|
|
|
68
84
|
|
|
69
85
|
private isLaunchConfigToolsEnabled;
|
|
70
86
|
|
|
87
|
+
private isHireToolsEnabled;
|
|
88
|
+
|
|
71
89
|
private fetchRuntimeFeaturesWithStatus;
|
|
72
90
|
private static readonly MEMORY_TYPES;
|
|
73
91
|
private static readonly MEMORY_TOOL_NAMES;
|
|
@@ -198,6 +216,9 @@ export declare class McpServer {
|
|
|
198
216
|
|
|
199
217
|
private handleIncomingMessage;
|
|
200
218
|
|
|
219
|
+
private handleSystemNotify;
|
|
220
|
+
private parseNotificationPayload;
|
|
221
|
+
|
|
201
222
|
private shouldBufferIncomingMessage;
|
|
202
223
|
|
|
203
224
|
private isGroupMessage;
|