aws-runtime-bridge 1.8.3 → 1.8.5
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/dist/config.d.ts.map +1 -1
- package/dist/config.js +28 -4
- package/dist/config.test.js +26 -1
- package/dist/routes/instance.d.ts.map +1 -1
- package/dist/routes/instance.js +31 -14
- package/dist/routes/instance.test.js +228 -168
- package/package.json +1 -1
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,WAAW;AACX,eAAO,MAAM,IAAI,EAAE,MAElB,CAAC;AAEF,kBAAkB;AAClB,eAAO,MAAM,0BAA0B,0BAA0B,CAAC;AAElE,MAAM,MAAM,sBAAsB,GAC9B,SAAS,GACT,KAAK,GACL,6BAA6B,GAC7B,SAAS,GACT,iCAAiC,CAAC;AAEtC,MAAM,WAAW,0BAA0B;IACzC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,sBAAsB,CAAC;IAC/B,mBAAmB,EAAE,MAAM,EAAE,CAAC;CAC/B;AA+
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/config.js
CHANGED
|
@@ -41,6 +41,19 @@ function normalizeSchedulerHttpBaseUrl(value) {
|
|
|
41
41
|
return repaired.replace(/\/+$/, "");
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
function isLoopbackSchedulerBaseUrl(value) {
|
|
45
|
+
try {
|
|
46
|
+
const url = new URL(value);
|
|
47
|
+
const hostname = url.hostname.toLowerCase();
|
|
48
|
+
return (hostname === "localhost" ||
|
|
49
|
+
hostname === "127.0.0.1" ||
|
|
50
|
+
hostname === "::1" ||
|
|
51
|
+
hostname === "[::1]");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(?:\/|$)/i.test(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
44
57
|
function getAutoRegisterConfigFilePath() {
|
|
45
58
|
const testHomeDir = String(process.env.AWS_TEST_HOME || "").trim();
|
|
46
59
|
return path.join(testHomeDir || homedir(), ".aws-bridge", "config.json");
|
|
@@ -84,14 +97,26 @@ export function resolveSchedulerBaseUrl() {
|
|
|
84
97
|
*/
|
|
85
98
|
export function resolveSchedulerBaseUrlFrom(requestSchedulerBaseUrl) {
|
|
86
99
|
const normalizedRequestSchedulerBaseUrl = normalizeSchedulerHttpBaseUrl(requestSchedulerBaseUrl);
|
|
100
|
+
const envSchedulerBaseUrl = normalizeSchedulerHttpBaseUrl(process.env.AWS_RUNTIME_SCHEDULER_BASE_URL);
|
|
101
|
+
const autoRegisterTargetUrls = readAutoRegisterTargetUrls();
|
|
102
|
+
const singleAutoRegisterTargetUrl = autoRegisterTargetUrls.length === 1 ? autoRegisterTargetUrls[0] : undefined;
|
|
87
103
|
if (normalizedRequestSchedulerBaseUrl) {
|
|
104
|
+
const localSchedulerBaseUrl = envSchedulerBaseUrl || singleAutoRegisterTargetUrl;
|
|
105
|
+
if (localSchedulerBaseUrl &&
|
|
106
|
+
isLoopbackSchedulerBaseUrl(normalizedRequestSchedulerBaseUrl) &&
|
|
107
|
+
!isLoopbackSchedulerBaseUrl(localSchedulerBaseUrl)) {
|
|
108
|
+
return {
|
|
109
|
+
url: localSchedulerBaseUrl,
|
|
110
|
+
source: envSchedulerBaseUrl ? "env" : "single-auto-register-target",
|
|
111
|
+
ambiguousTargetUrls: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
88
114
|
return {
|
|
89
115
|
url: normalizedRequestSchedulerBaseUrl,
|
|
90
116
|
source: "request",
|
|
91
117
|
ambiguousTargetUrls: [],
|
|
92
118
|
};
|
|
93
119
|
}
|
|
94
|
-
const envSchedulerBaseUrl = normalizeSchedulerHttpBaseUrl(process.env.AWS_RUNTIME_SCHEDULER_BASE_URL);
|
|
95
120
|
if (envSchedulerBaseUrl) {
|
|
96
121
|
return {
|
|
97
122
|
url: envSchedulerBaseUrl,
|
|
@@ -99,10 +124,9 @@ export function resolveSchedulerBaseUrlFrom(requestSchedulerBaseUrl) {
|
|
|
99
124
|
ambiguousTargetUrls: [],
|
|
100
125
|
};
|
|
101
126
|
}
|
|
102
|
-
|
|
103
|
-
if (autoRegisterTargetUrls.length === 1) {
|
|
127
|
+
if (singleAutoRegisterTargetUrl) {
|
|
104
128
|
return {
|
|
105
|
-
url:
|
|
129
|
+
url: singleAutoRegisterTargetUrl || DEFAULT_SCHEDULER_BASE_URL,
|
|
106
130
|
source: "single-auto-register-target",
|
|
107
131
|
ambiguousTargetUrls: [],
|
|
108
132
|
};
|
package/dist/config.test.js
CHANGED
|
@@ -57,7 +57,8 @@ describe("scheduler base URL resolution", () => {
|
|
|
57
57
|
it("keeps explicit AWS_RUNTIME_SCHEDULER_BASE_URL as the highest priority", async () => {
|
|
58
58
|
const runtimeHome = createRuntimeHome();
|
|
59
59
|
useRuntimeHome(runtimeHome);
|
|
60
|
-
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL =
|
|
60
|
+
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL =
|
|
61
|
+
"http://explicit.local:7380/path";
|
|
61
62
|
writeBridgeConfig(runtimeHome, [{ serverUrl: "http://target.local:7380" }]);
|
|
62
63
|
const { resolveSchedulerBaseUrl } = await import("./config.js");
|
|
63
64
|
expect(resolveSchedulerBaseUrl()).toEqual({
|
|
@@ -66,6 +67,30 @@ describe("scheduler base URL resolution", () => {
|
|
|
66
67
|
ambiguousTargetUrls: [],
|
|
67
68
|
});
|
|
68
69
|
});
|
|
70
|
+
it("ignores a server-supplied loopback URL when bridge has an explicit external scheduler URL", async () => {
|
|
71
|
+
const runtimeHome = createRuntimeHome();
|
|
72
|
+
useRuntimeHome(runtimeHome);
|
|
73
|
+
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL = "http://101.42.200.84:7380";
|
|
74
|
+
const { resolveSchedulerBaseUrlFrom } = await import("./config.js");
|
|
75
|
+
expect(resolveSchedulerBaseUrlFrom("http://127.0.0.1:7380/api")).toEqual({
|
|
76
|
+
url: "http://101.42.200.84:7380",
|
|
77
|
+
source: "env",
|
|
78
|
+
ambiguousTargetUrls: [],
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
it("uses a single external auto-register target instead of a server-supplied loopback URL", async () => {
|
|
82
|
+
const runtimeHome = createRuntimeHome();
|
|
83
|
+
useRuntimeHome(runtimeHome);
|
|
84
|
+
writeBridgeConfig(runtimeHome, [
|
|
85
|
+
{ serverUrl: "http://101.42.200.84:7380" },
|
|
86
|
+
]);
|
|
87
|
+
const { resolveSchedulerBaseUrlFrom } = await import("./config.js");
|
|
88
|
+
expect(resolveSchedulerBaseUrlFrom("http://127.0.0.1:7380/api")).toEqual({
|
|
89
|
+
url: "http://101.42.200.84:7380",
|
|
90
|
+
source: "single-auto-register-target",
|
|
91
|
+
ambiguousTargetUrls: [],
|
|
92
|
+
});
|
|
93
|
+
});
|
|
69
94
|
it("uses a single auto-register target serverUrl when scheduler env is absent", async () => {
|
|
70
95
|
const runtimeHome = createRuntimeHome();
|
|
71
96
|
useRuntimeHome(runtimeHome);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/routes/instance.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/routes/instance.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA0BrD,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAC/D,IAAI,CAEN;AAED,eAAO,MAAM,cAAc,4CAAW,CAAC;AAwJvC,wBAAgB,4BAA4B,CAAC,gBAAgB,EAAE,MAAM,GAAG;IACtE,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE;QACJ,EAAE,EAAE,OAAO,CAAC;QACZ,aAAa,CAAC,EAAE,SAAS,CAAC;QAC1B,oBAAoB,EAAE,OAAO,CAAC;QAC9B,qBAAqB,EAAE,OAAO,CAAC;QAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH,CAqCA;AAED,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,gBAAgB,CAAC;IAC/B,aAAa,EAAE,SAAS,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,iCAAiC;IACzC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,8BAA8B,CAAC;IACtC,YAAY,EAAE,gBAAgB,CAAC;IAC/B,aAAa,EAAE,SAAS,CAAC;IACzB,gBAAgB,EAAE,MAAM,CAAC;IACzB,sBAAsB,EAAE,MAAM,EAAE,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,KAAK,EACZ,0BAA0B,EAAE,MAAM,GACjC,4BAA4B,CAgB9B;AAmDD;;;GAGG;AACH,wBAAgB,sCAAsC,CACpD,UAAU,EAAE,MAAM,EAAE,GACnB,iCAAiC,CAUnC;AAED;;;GAGG;AACH,wBAAgB,+BAA+B,CAC7C,YAAY,EAAE,MAAM,EAAE,GACrB,MAAM,EAAE,CAYV;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,cAAc,EAAE,MAAM,EAAE,EACxB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAC5C,MAAM,CAgBR;AAED,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,GAC5C,MAAM,CACP,MAAM,EACN,IAAI,CAAC,iBAAiB,EAAE,WAAW,GAAG,SAAS,GAAG,YAAY,GAAG,OAAO,CAAC,CAC1E,CAYA"}
|
package/dist/routes/instance.js
CHANGED
|
@@ -4,16 +4,21 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { resolveSchedulerBaseUrlFrom } from "../config.js";
|
|
6
6
|
import { validateToken } from "../middleware/auth.js";
|
|
7
|
-
import { getConfiguredConnectionKeys, requestRuntimeAccessTokenRefreshForServer } from "../services/auto-register.js";
|
|
8
|
-
import { discoverCcSwitchConfiguredItems, loadCcSwitchSdk } from "../services/cc-switch-sdk.js";
|
|
9
|
-
import { initInstance, syncLegacyStateFromSdk } from "../services/instance-init-service.js";
|
|
7
|
+
import { getConfiguredConnectionKeys, requestRuntimeAccessTokenRefreshForServer, } from "../services/auto-register.js";
|
|
8
|
+
import { discoverCcSwitchConfiguredItems, loadCcSwitchSdk, } from "../services/cc-switch-sdk.js";
|
|
9
|
+
import { initInstance, syncLegacyStateFromSdk, } from "../services/instance-init-service.js";
|
|
10
10
|
import { createBridgeLifecycleOperation, deliverPendingBridgeLifecycleNotifications, upsertBridgeLifecycleOperation, } from "../services/lifecycle-state.js";
|
|
11
11
|
import { loadInstanceState, saveInstanceState, } from "../services/instance-state.js";
|
|
12
12
|
import { getRuntimeAccessToken } from "../services/runtime-binding.js";
|
|
13
13
|
import { detectToolStatuses, ensureToolsInstalled, SUPPORTED_INSTALLABLE_TOOLS, SUPPORTED_UNINSTALLABLE_TOOLS, uninstallTools, } from "../services/tool-installer.js";
|
|
14
14
|
import { createLogger } from "../utils/logger.js";
|
|
15
15
|
const log = createLogger("instance");
|
|
16
|
-
const PANEL_TOOL_STATUS_KEYS = [
|
|
16
|
+
const PANEL_TOOL_STATUS_KEYS = [
|
|
17
|
+
"acode",
|
|
18
|
+
"claude",
|
|
19
|
+
"opencode",
|
|
20
|
+
"codex",
|
|
21
|
+
];
|
|
17
22
|
// ★★★ 导出 gracefulShutdown 的触发函数
|
|
18
23
|
// 用于 aws-mcp-server 通知 bridge 保留会话状态关闭
|
|
19
24
|
let gracefulShutdownFn = null;
|
|
@@ -249,7 +254,9 @@ export function buildAmbiguousSchedulerBaseUrlResponse(targetUrls) {
|
|
|
249
254
|
*/
|
|
250
255
|
export function buildToolStatusDetectionTargets(enabledTools) {
|
|
251
256
|
return Array.from(new Set([...enabledTools, ...PANEL_TOOL_STATUS_KEYS]
|
|
252
|
-
.map((tool) => String(tool || "")
|
|
257
|
+
.map((tool) => String(tool || "")
|
|
258
|
+
.trim()
|
|
259
|
+
.toLowerCase())
|
|
253
260
|
.filter(Boolean)));
|
|
254
261
|
}
|
|
255
262
|
/**
|
|
@@ -300,17 +307,25 @@ instanceRouter.get("/ping", validateToken, async (req, res) => {
|
|
|
300
307
|
}
|
|
301
308
|
const schedulerBaseUrl = schedulerBaseUrlResolution.url;
|
|
302
309
|
try {
|
|
303
|
-
|
|
310
|
+
let runtimeAccessToken = getRuntimeAccessToken(undefined, schedulerBaseUrl);
|
|
311
|
+
let tokenRefreshed = false;
|
|
304
312
|
if (!runtimeAccessToken) {
|
|
305
|
-
|
|
306
|
-
|
|
313
|
+
const refreshResult = await requestRuntimeAccessTokenRefreshForServer(schedulerBaseUrl);
|
|
314
|
+
if (!refreshResult.success || !refreshResult.runtimeAccessToken) {
|
|
315
|
+
res
|
|
316
|
+
.status(401)
|
|
317
|
+
.json({ ok: false, error: "runtime_access_token_required" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
runtimeAccessToken = refreshResult.runtimeAccessToken;
|
|
321
|
+
tokenRefreshed = true;
|
|
307
322
|
}
|
|
308
323
|
const schedulerPing = await pingSchedulerWithToken(schedulerBaseUrl, runtimeAccessToken);
|
|
309
324
|
res.json({
|
|
310
325
|
ok: true,
|
|
311
326
|
runtimeBridge: "healthy",
|
|
312
327
|
schedulerReachable: schedulerPing.schedulerReachable,
|
|
313
|
-
tokenRefreshed: schedulerPing.tokenRefreshed,
|
|
328
|
+
tokenRefreshed: tokenRefreshed || schedulerPing.tokenRefreshed,
|
|
314
329
|
schedulerBaseUrl,
|
|
315
330
|
});
|
|
316
331
|
}
|
|
@@ -371,7 +386,9 @@ instanceRouter.post("/cc-switch/state", validateToken, async (req, res) => {
|
|
|
371
386
|
return;
|
|
372
387
|
}
|
|
373
388
|
try {
|
|
374
|
-
log.info("[cc-switch/state] Request received", {
|
|
389
|
+
log.info("[cc-switch/state] Request received", {
|
|
390
|
+
agentId: String(agentId),
|
|
391
|
+
});
|
|
375
392
|
const sdk = await loadCcSwitchSdk(agentId);
|
|
376
393
|
const imported = await discoverCcSwitchConfiguredItems(sdk);
|
|
377
394
|
const synced = await syncLegacyStateFromSdk(agentId, sdk);
|
|
@@ -438,9 +455,7 @@ instanceRouter.post("/cc-switch/install-tools", validateToken, async (req, res)
|
|
|
438
455
|
agentId: String(agentId),
|
|
439
456
|
requestedTools,
|
|
440
457
|
});
|
|
441
|
-
res
|
|
442
|
-
.status(400)
|
|
443
|
-
.json({
|
|
458
|
+
res.status(400).json({
|
|
444
459
|
error: `tools must include ${SUPPORTED_INSTALLABLE_TOOLS.join(", ")}`,
|
|
445
460
|
});
|
|
446
461
|
return;
|
|
@@ -533,7 +548,9 @@ instanceRouter.post("/cc-switch/uninstall-tools", validateToken, async (req, res
|
|
|
533
548
|
try {
|
|
534
549
|
const state = await loadInstanceState(agentId);
|
|
535
550
|
const uninstallableToolSet = new Set(uninstallableTools);
|
|
536
|
-
const enabledTools = (state.enabledTools || []).filter((tool) => !uninstallableToolSet.has(String(tool || "")
|
|
551
|
+
const enabledTools = (state.enabledTools || []).filter((tool) => !uninstallableToolSet.has(String(tool || "")
|
|
552
|
+
.trim()
|
|
553
|
+
.toLowerCase()));
|
|
537
554
|
const uninstalledToolStatus = await uninstallTools(uninstallableTools);
|
|
538
555
|
const refreshedToolStatus = await detectToolStatuses([
|
|
539
556
|
...enabledTools,
|
|
@@ -1,39 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Instance 路由单元测试
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi, afterEach } from
|
|
5
|
-
import { EventEmitter } from
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
6
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
7
|
-
vi.mock(
|
|
7
|
+
vi.mock("axios", () => ({
|
|
8
8
|
default: {
|
|
9
9
|
get: vi.fn(),
|
|
10
10
|
post: vi.fn(),
|
|
11
11
|
},
|
|
12
12
|
}));
|
|
13
|
-
vi.mock(
|
|
13
|
+
vi.mock("../services/auto-register.js", () => ({
|
|
14
14
|
getConfiguredConnectionKeys: vi.fn(() => []),
|
|
15
15
|
requestRuntimeAccessTokenRefreshForServer: vi.fn(),
|
|
16
16
|
}));
|
|
17
|
-
vi.mock(
|
|
18
|
-
getRuntimeAccessToken: vi.fn(() =>
|
|
19
|
-
loadRuntimeBinding: vi.fn(() => ({
|
|
20
|
-
|
|
17
|
+
vi.mock("../services/runtime-binding.js", () => ({
|
|
18
|
+
getRuntimeAccessToken: vi.fn(() => "stale-runtime-token-123456"),
|
|
19
|
+
loadRuntimeBinding: vi.fn(() => ({
|
|
20
|
+
status: "paired",
|
|
21
|
+
userId: "user-1",
|
|
22
|
+
schedulerBaseUrl: "http://server.local:7380",
|
|
23
|
+
})),
|
|
24
|
+
normalizeSchedulerBaseUrl: vi.fn((value) => value ? String(value).replace(/\/+$/, "") : undefined),
|
|
21
25
|
}));
|
|
22
|
-
vi.mock(
|
|
26
|
+
vi.mock("../services/lifecycle-state.js", () => ({
|
|
23
27
|
createBridgeLifecycleOperation: vi.fn((input) => ({
|
|
24
|
-
operationId:
|
|
25
|
-
requestedAt:
|
|
26
|
-
updatedAt:
|
|
28
|
+
operationId: "operation-1",
|
|
29
|
+
requestedAt: "2026-01-01T00:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
27
31
|
...input,
|
|
28
32
|
})),
|
|
29
33
|
deliverPendingBridgeLifecycleNotifications: vi.fn(),
|
|
30
34
|
upsertBridgeLifecycleOperation: vi.fn(),
|
|
31
35
|
}));
|
|
32
|
-
vi.mock(
|
|
36
|
+
vi.mock("../config.js", async (importOriginal) => ({
|
|
33
37
|
...(await importOriginal()),
|
|
34
38
|
getRuntimeHomeDir: vi.fn(() => process.cwd()),
|
|
35
39
|
}));
|
|
36
|
-
vi.mock(
|
|
40
|
+
vi.mock("node:child_process", async (importOriginal) => ({
|
|
37
41
|
...(await importOriginal()),
|
|
38
42
|
spawn: spawnMock,
|
|
39
43
|
}));
|
|
@@ -41,19 +45,19 @@ afterEach(() => {
|
|
|
41
45
|
vi.useRealTimers();
|
|
42
46
|
vi.clearAllMocks();
|
|
43
47
|
});
|
|
44
|
-
function mockSpawnResult(exitCode, stdoutText =
|
|
48
|
+
function mockSpawnResult(exitCode, stdoutText = "", stderrText = "") {
|
|
45
49
|
spawnMock.mockImplementationOnce(() => {
|
|
46
50
|
const child = new EventEmitter();
|
|
47
51
|
child.stdout = new EventEmitter();
|
|
48
52
|
child.stderr = new EventEmitter();
|
|
49
53
|
process.nextTick(() => {
|
|
50
54
|
if (stdoutText) {
|
|
51
|
-
child.stdout.emit(
|
|
55
|
+
child.stdout.emit("data", Buffer.from(stdoutText));
|
|
52
56
|
}
|
|
53
57
|
if (stderrText) {
|
|
54
|
-
child.stderr.emit(
|
|
58
|
+
child.stderr.emit("data", Buffer.from(stderrText));
|
|
55
59
|
}
|
|
56
|
-
child.emit(
|
|
60
|
+
child.emit("close", exitCode);
|
|
57
61
|
});
|
|
58
62
|
return child;
|
|
59
63
|
});
|
|
@@ -63,30 +67,33 @@ function createMockResponse() {
|
|
|
63
67
|
const status = vi.fn(() => ({ json }));
|
|
64
68
|
return { json, status };
|
|
65
69
|
}
|
|
66
|
-
describe(
|
|
67
|
-
it(
|
|
70
|
+
describe("instance route validation", () => {
|
|
71
|
+
it("builds correct ping response when healthy", () => {
|
|
68
72
|
const buildPingResponse = (schedulerStatus, schedulerBaseUrl) => ({
|
|
69
73
|
ok: true,
|
|
70
|
-
runtimeBridge:
|
|
74
|
+
runtimeBridge: "healthy",
|
|
71
75
|
schedulerReachable: schedulerStatus >= 200 && schedulerStatus < 300,
|
|
72
76
|
schedulerBaseUrl,
|
|
73
77
|
});
|
|
74
|
-
const healthyResponse = buildPingResponse(200,
|
|
78
|
+
const healthyResponse = buildPingResponse(200, "http://localhost:8080");
|
|
75
79
|
expect(healthyResponse.ok).toBe(true);
|
|
76
80
|
expect(healthyResponse.schedulerReachable).toBe(true);
|
|
77
|
-
const unhealthyResponse = buildPingResponse(502,
|
|
81
|
+
const unhealthyResponse = buildPingResponse(502, "http://localhost:8080");
|
|
78
82
|
expect(unhealthyResponse.schedulerReachable).toBe(false);
|
|
79
83
|
});
|
|
80
|
-
it(
|
|
84
|
+
it("requires agentId and workspacePath", () => {
|
|
81
85
|
const validateInitRequest = (body) => {
|
|
82
86
|
if (!body.agentId || !body.workspacePath)
|
|
83
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
error: "agentId and workspacePath are required",
|
|
90
|
+
};
|
|
84
91
|
return { valid: true };
|
|
85
92
|
};
|
|
86
93
|
expect(validateInitRequest({}).valid).toBe(false);
|
|
87
|
-
expect(validateInitRequest({ agentId:
|
|
94
|
+
expect(validateInitRequest({ agentId: "agent-1", workspacePath: "/path" }).valid).toBe(true);
|
|
88
95
|
});
|
|
89
|
-
it(
|
|
96
|
+
it("builds correct init options", () => {
|
|
90
97
|
const buildInitOptions = (body) => ({
|
|
91
98
|
skillEnabled: body.skillEnabled,
|
|
92
99
|
mcpEnabled: body.mcpEnabled,
|
|
@@ -95,87 +102,91 @@ describe('instance route validation', () => {
|
|
|
95
102
|
const options = buildInitOptions({
|
|
96
103
|
skillEnabled: true,
|
|
97
104
|
mcpEnabled: false,
|
|
98
|
-
ccSwitchEnabledTools: [
|
|
105
|
+
ccSwitchEnabledTools: ["claude", "opencode"],
|
|
99
106
|
});
|
|
100
107
|
expect(options.skillEnabled).toBe(true);
|
|
101
108
|
expect(options.mcpEnabled).toBe(false);
|
|
102
|
-
expect(options.ccSwitchEnabledTools).toEqual([
|
|
109
|
+
expect(options.ccSwitchEnabledTools).toEqual(["claude", "opencode"]);
|
|
103
110
|
});
|
|
104
|
-
it(
|
|
111
|
+
it("requires agentId for state query", () => {
|
|
105
112
|
const validateStateRequest = (body) => {
|
|
106
113
|
if (!body.agentId)
|
|
107
|
-
return { valid: false, error:
|
|
114
|
+
return { valid: false, error: "agentId is required" };
|
|
108
115
|
return { valid: true };
|
|
109
116
|
};
|
|
110
117
|
expect(validateStateRequest({}).valid).toBe(false);
|
|
111
|
-
expect(validateStateRequest({ agentId:
|
|
118
|
+
expect(validateStateRequest({ agentId: "agent-1" }).valid).toBe(true);
|
|
112
119
|
});
|
|
113
|
-
it(
|
|
114
|
-
const { buildToolStatusDetectionTargets } = await import(
|
|
115
|
-
expect(buildToolStatusDetectionTargets([
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
it("always detects panel tool statuses even when legacy enabled tools omit codex", async () => {
|
|
121
|
+
const { buildToolStatusDetectionTargets } = await import("./instance.js");
|
|
122
|
+
expect(buildToolStatusDetectionTargets(["claude", "opencode"])).toEqual([
|
|
123
|
+
"claude",
|
|
124
|
+
"opencode",
|
|
125
|
+
"acode",
|
|
126
|
+
"codex",
|
|
120
127
|
]);
|
|
121
128
|
}, 15_000);
|
|
122
|
-
it(
|
|
123
|
-
const { SUPPORTED_INSTALLABLE_TOOLS } = await import(
|
|
129
|
+
it("accepts codex for cc-switch tool installation validation", async () => {
|
|
130
|
+
const { SUPPORTED_INSTALLABLE_TOOLS } = await import("../services/tool-installer.js");
|
|
124
131
|
const normalizeInstallableTools = (tools) => {
|
|
125
132
|
const supported = new Set(SUPPORTED_INSTALLABLE_TOOLS);
|
|
126
133
|
const requestedTools = Array.isArray(tools)
|
|
127
|
-
? tools
|
|
134
|
+
? tools
|
|
135
|
+
.map((tool) => String(tool || "")
|
|
136
|
+
.trim()
|
|
137
|
+
.toLowerCase())
|
|
138
|
+
.filter(Boolean)
|
|
128
139
|
: [];
|
|
129
140
|
return requestedTools.filter((tool) => supported.has(tool));
|
|
130
141
|
};
|
|
131
|
-
expect(normalizeInstallableTools([
|
|
132
|
-
expect(SUPPORTED_INSTALLABLE_TOOLS).toContain(
|
|
142
|
+
expect(normalizeInstallableTools(["Codex"])).toEqual(["codex"]);
|
|
143
|
+
expect(SUPPORTED_INSTALLABLE_TOOLS).toContain("codex");
|
|
133
144
|
});
|
|
134
|
-
it(
|
|
135
|
-
const { SUPPORTED_UNINSTALLABLE_TOOLS } = await import(
|
|
145
|
+
it("accepts panel tools for cc-switch tool uninstallation validation", async () => {
|
|
146
|
+
const { SUPPORTED_UNINSTALLABLE_TOOLS } = await import("../services/tool-installer.js");
|
|
136
147
|
const normalizeUninstallableTools = (tools) => {
|
|
137
148
|
const supported = new Set(SUPPORTED_UNINSTALLABLE_TOOLS);
|
|
138
149
|
const requestedTools = Array.isArray(tools)
|
|
139
|
-
? tools
|
|
150
|
+
? tools
|
|
151
|
+
.map((tool) => String(tool || "")
|
|
152
|
+
.trim()
|
|
153
|
+
.toLowerCase())
|
|
154
|
+
.filter(Boolean)
|
|
140
155
|
: [];
|
|
141
156
|
return requestedTools.filter((tool) => supported.has(tool));
|
|
142
157
|
};
|
|
143
|
-
expect(normalizeUninstallableTools([
|
|
144
|
-
|
|
145
|
-
'opencode',
|
|
146
|
-
'codex',
|
|
147
|
-
]);
|
|
148
|
-
expect(normalizeUninstallableTools(['unknown-tool'])).toEqual([]);
|
|
158
|
+
expect(normalizeUninstallableTools(["Claude", "OpenCode", "Codex"])).toEqual(["claude", "opencode", "codex"]);
|
|
159
|
+
expect(normalizeUninstallableTools(["unknown-tool"])).toEqual([]);
|
|
149
160
|
});
|
|
150
|
-
it(
|
|
151
|
-
const { buildUninstallFailureMessage } = await import(
|
|
152
|
-
expect(buildUninstallFailureMessage([
|
|
161
|
+
it("reports uninstall failure when a requested tool remains installed", async () => {
|
|
162
|
+
const { buildUninstallFailureMessage } = await import("./instance.js");
|
|
163
|
+
expect(buildUninstallFailureMessage(["opencode"], {
|
|
153
164
|
opencode: {
|
|
154
|
-
tool:
|
|
165
|
+
tool: "opencode",
|
|
155
166
|
installed: true,
|
|
156
|
-
executable:
|
|
157
|
-
version:
|
|
167
|
+
executable: "/home/user/.opencode/bin/opencode",
|
|
168
|
+
version: "1.14.39",
|
|
158
169
|
installing: false,
|
|
159
|
-
error:
|
|
170
|
+
error: "uninstall completed but command is still available",
|
|
160
171
|
},
|
|
161
|
-
})).toContain(
|
|
172
|
+
})).toContain("opencode: uninstall completed but command is still available");
|
|
162
173
|
});
|
|
163
|
-
it(
|
|
164
|
-
const { summarizeToolStatus } = await import(
|
|
174
|
+
it("summarizes tool status for route diagnostics", async () => {
|
|
175
|
+
const { summarizeToolStatus } = await import("./instance.js");
|
|
165
176
|
expect(summarizeToolStatus({
|
|
166
177
|
opencode: {
|
|
167
|
-
tool:
|
|
178
|
+
tool: "opencode",
|
|
168
179
|
installed: false,
|
|
169
180
|
executable: null,
|
|
170
181
|
version: null,
|
|
171
182
|
installing: false,
|
|
172
|
-
error:
|
|
183
|
+
error: "SDK package @opencode-ai/sdk is not installed in aws-runtime-bridge",
|
|
173
184
|
},
|
|
174
185
|
claude: {
|
|
175
|
-
tool:
|
|
186
|
+
tool: "claude",
|
|
176
187
|
installed: true,
|
|
177
|
-
executable:
|
|
178
|
-
version:
|
|
188
|
+
executable: "@anthropic-ai/claude-agent-sdk",
|
|
189
|
+
version: "0.2.87",
|
|
179
190
|
installing: false,
|
|
180
191
|
error: null,
|
|
181
192
|
},
|
|
@@ -184,202 +195,251 @@ describe('instance route validation', () => {
|
|
|
184
195
|
installed: false,
|
|
185
196
|
executable: null,
|
|
186
197
|
version: null,
|
|
187
|
-
error:
|
|
198
|
+
error: "SDK package @opencode-ai/sdk is not installed in aws-runtime-bridge",
|
|
188
199
|
},
|
|
189
200
|
claude: {
|
|
190
201
|
installed: true,
|
|
191
|
-
executable:
|
|
192
|
-
version:
|
|
202
|
+
executable: "@anthropic-ai/claude-agent-sdk",
|
|
203
|
+
version: "0.2.87",
|
|
193
204
|
error: null,
|
|
194
205
|
},
|
|
195
206
|
});
|
|
196
207
|
});
|
|
197
|
-
it(
|
|
198
|
-
const { buildUninstallFailureMessage } = await import(
|
|
199
|
-
expect(buildUninstallFailureMessage([
|
|
208
|
+
it("does not report uninstall failure when requested tools are no longer installed", async () => {
|
|
209
|
+
const { buildUninstallFailureMessage } = await import("./instance.js");
|
|
210
|
+
expect(buildUninstallFailureMessage(["claude"], {
|
|
200
211
|
claude: {
|
|
201
|
-
tool:
|
|
212
|
+
tool: "claude",
|
|
202
213
|
installed: false,
|
|
203
214
|
executable: null,
|
|
204
215
|
version: null,
|
|
205
216
|
installing: false,
|
|
206
|
-
error:
|
|
217
|
+
error: "command not installed",
|
|
207
218
|
},
|
|
208
|
-
})).toBe(
|
|
219
|
+
})).toBe("");
|
|
209
220
|
});
|
|
210
|
-
it(
|
|
211
|
-
const { getConfiguredConnectionKeys } = await import(
|
|
221
|
+
it("allows connection check when no connection key is configured", async () => {
|
|
222
|
+
const { getConfiguredConnectionKeys } = await import("../services/auto-register.js");
|
|
212
223
|
vi.mocked(getConfiguredConnectionKeys).mockReturnValue([]);
|
|
213
|
-
const { buildConnectionCheckResponse } = await import(
|
|
214
|
-
expect(buildConnectionCheckResponse(
|
|
224
|
+
const { buildConnectionCheckResponse } = await import("./instance.js");
|
|
225
|
+
expect(buildConnectionCheckResponse("")).toEqual({
|
|
215
226
|
status: 200,
|
|
216
227
|
body: {
|
|
217
228
|
ok: true,
|
|
218
|
-
runtimeBridge:
|
|
229
|
+
runtimeBridge: "healthy",
|
|
219
230
|
connectionKeyMatched: true,
|
|
220
231
|
connectionKeyRequired: false,
|
|
221
232
|
},
|
|
222
233
|
});
|
|
223
234
|
});
|
|
224
|
-
it(
|
|
225
|
-
const { createHash } = await import(
|
|
226
|
-
const { getConfiguredConnectionKeys } = await import(
|
|
227
|
-
vi.mocked(getConfiguredConnectionKeys).mockReturnValue([
|
|
228
|
-
const { buildConnectionCheckResponse } = await import(
|
|
229
|
-
const digest = createHash(
|
|
235
|
+
it("requires matching connection key digest when a connection key is configured", async () => {
|
|
236
|
+
const { createHash } = await import("node:crypto");
|
|
237
|
+
const { getConfiguredConnectionKeys } = await import("../services/auto-register.js");
|
|
238
|
+
vi.mocked(getConfiguredConnectionKeys).mockReturnValue(["secret-key"]);
|
|
239
|
+
const { buildConnectionCheckResponse } = await import("./instance.js");
|
|
240
|
+
const digest = createHash("md5").update("secret-key", "utf8").digest("hex");
|
|
230
241
|
expect(buildConnectionCheckResponse(digest).status).toBe(200);
|
|
231
|
-
expect(buildConnectionCheckResponse(
|
|
242
|
+
expect(buildConnectionCheckResponse("00000000000000000000000000000000")).toEqual({
|
|
232
243
|
status: 401,
|
|
233
244
|
body: {
|
|
234
245
|
ok: false,
|
|
235
|
-
error:
|
|
246
|
+
error: "connection_key_mismatch",
|
|
236
247
|
connectionKeyMatched: false,
|
|
237
248
|
connectionKeyRequired: true,
|
|
238
249
|
},
|
|
239
250
|
});
|
|
240
251
|
});
|
|
241
|
-
it(
|
|
242
|
-
const { buildSchedulerPingFailureResponse } = await import(
|
|
243
|
-
const response = buildSchedulerPingFailureResponse(new Error(
|
|
252
|
+
it("explains localhost scheduler ping failures with environment variable guidance", async () => {
|
|
253
|
+
const { buildSchedulerPingFailureResponse } = await import("./instance.js");
|
|
254
|
+
const response = buildSchedulerPingFailureResponse(new Error("connect ECONNREFUSED 127.0.0.1:8080"), "http://localhost:8080");
|
|
244
255
|
expect(response).toEqual({
|
|
245
256
|
ok: false,
|
|
246
|
-
error:
|
|
247
|
-
failureStage:
|
|
248
|
-
runtimeBridge:
|
|
249
|
-
schedulerBaseUrl:
|
|
250
|
-
hint: expect.stringContaining(
|
|
257
|
+
error: "connect ECONNREFUSED 127.0.0.1:8080",
|
|
258
|
+
failureStage: "scheduler_ping",
|
|
259
|
+
runtimeBridge: "healthy",
|
|
260
|
+
schedulerBaseUrl: "http://localhost:8080",
|
|
261
|
+
hint: expect.stringContaining("AWS_RUNTIME_SCHEDULER_BASE_URL"),
|
|
251
262
|
});
|
|
252
|
-
expect(response.hint).toContain(
|
|
263
|
+
expect(response.hint).toContain("http://<server-host>:7380");
|
|
253
264
|
});
|
|
254
|
-
it(
|
|
255
|
-
const { buildSchedulerPingFailureResponse } = await import(
|
|
256
|
-
const response = buildSchedulerPingFailureResponse(new Error(
|
|
257
|
-
expect(response.schedulerBaseUrl).toBe(
|
|
258
|
-
expect(response.failureStage).toBe(
|
|
259
|
-
expect(response.runtimeBridge).toBe(
|
|
260
|
-
expect(response.hint).toContain(
|
|
265
|
+
it("explains non-localhost scheduler ping failures as reachability or token issues", async () => {
|
|
266
|
+
const { buildSchedulerPingFailureResponse } = await import("./instance.js");
|
|
267
|
+
const response = buildSchedulerPingFailureResponse(new Error("Request failed with status code 401"), "http://10.0.0.8:7380");
|
|
268
|
+
expect(response.schedulerBaseUrl).toBe("http://10.0.0.8:7380");
|
|
269
|
+
expect(response.failureStage).toBe("scheduler_ping");
|
|
270
|
+
expect(response.runtimeBridge).toBe("healthy");
|
|
271
|
+
expect(response.hint).toContain("运行时访问令牌仍有效");
|
|
261
272
|
});
|
|
262
|
-
it(
|
|
263
|
-
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL =
|
|
264
|
-
const { default: axios } = await import(
|
|
265
|
-
const { requestRuntimeAccessTokenRefreshForServer } = await import(
|
|
266
|
-
const { instanceRouter } = await import(
|
|
273
|
+
it("refreshes runtime token and retries scheduler ping after 401", async () => {
|
|
274
|
+
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL = "http://localhost:8080";
|
|
275
|
+
const { default: axios } = await import("axios");
|
|
276
|
+
const { requestRuntimeAccessTokenRefreshForServer } = await import("../services/auto-register.js");
|
|
277
|
+
const { instanceRouter } = await import("./instance.js");
|
|
267
278
|
vi.mocked(requestRuntimeAccessTokenRefreshForServer).mockResolvedValue({
|
|
268
279
|
success: true,
|
|
269
|
-
runtimeAccessToken:
|
|
280
|
+
runtimeAccessToken: "fresh-runtime-token-123456",
|
|
270
281
|
updated: true,
|
|
271
282
|
});
|
|
272
283
|
vi.mocked(axios.get)
|
|
273
|
-
.mockRejectedValueOnce({
|
|
284
|
+
.mockRejectedValueOnce({
|
|
285
|
+
response: { status: 401 },
|
|
286
|
+
message: "Request failed with status code 401",
|
|
287
|
+
})
|
|
274
288
|
.mockResolvedValueOnce({ status: 200 });
|
|
275
|
-
const handler = instanceRouter.stack.find((layer) => layer.route?.path ===
|
|
276
|
-
expect(handler).toBeTypeOf(
|
|
289
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/ping")?.route?.stack[1]?.handle;
|
|
290
|
+
expect(handler).toBeTypeOf("function");
|
|
277
291
|
const json = vi.fn();
|
|
278
292
|
const status = vi.fn(() => ({ json }));
|
|
279
|
-
await handler?.({ header: vi.fn(() =>
|
|
280
|
-
expect(requestRuntimeAccessTokenRefreshForServer).toHaveBeenCalledWith(
|
|
281
|
-
expect(axios.get).toHaveBeenNthCalledWith(1,
|
|
282
|
-
headers: {
|
|
293
|
+
await handler?.({ header: vi.fn(() => "") }, { json, status }, vi.fn());
|
|
294
|
+
expect(requestRuntimeAccessTokenRefreshForServer).toHaveBeenCalledWith("http://localhost:8080");
|
|
295
|
+
expect(axios.get).toHaveBeenNthCalledWith(1, "http://localhost:8080/api/runtime/ping", {
|
|
296
|
+
headers: { "X-Runtime-Token": "stale-runtime-token-123456" },
|
|
283
297
|
});
|
|
284
|
-
expect(axios.get).toHaveBeenNthCalledWith(2,
|
|
285
|
-
headers: {
|
|
298
|
+
expect(axios.get).toHaveBeenNthCalledWith(2, "http://localhost:8080/api/runtime/ping", {
|
|
299
|
+
headers: { "X-Runtime-Token": "fresh-runtime-token-123456" },
|
|
286
300
|
});
|
|
287
301
|
expect(json).toHaveBeenCalledWith({
|
|
288
302
|
ok: true,
|
|
289
|
-
runtimeBridge:
|
|
303
|
+
runtimeBridge: "healthy",
|
|
290
304
|
schedulerReachable: true,
|
|
291
305
|
tokenRefreshed: true,
|
|
292
|
-
schedulerBaseUrl:
|
|
306
|
+
schedulerBaseUrl: "http://localhost:8080",
|
|
293
307
|
});
|
|
294
308
|
expect(status).not.toHaveBeenCalled();
|
|
295
309
|
});
|
|
296
|
-
it(
|
|
297
|
-
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL =
|
|
298
|
-
const { default: axios } = await import(
|
|
299
|
-
const {
|
|
300
|
-
const {
|
|
301
|
-
|
|
310
|
+
it("uses scheduler base URL supplied by server ping request before local bridge config", async () => {
|
|
311
|
+
process.env.AWS_RUNTIME_SCHEDULER_BASE_URL = "http://localhost:8080";
|
|
312
|
+
const { default: axios } = await import("axios");
|
|
313
|
+
const { requestRuntimeAccessTokenRefreshForServer } = await import("../services/auto-register.js");
|
|
314
|
+
const { getRuntimeAccessToken } = await import("../services/runtime-binding.js");
|
|
315
|
+
const { instanceRouter } = await import("./instance.js");
|
|
316
|
+
vi.mocked(getRuntimeAccessToken).mockReturnValue("stale-runtime-token-123456");
|
|
317
|
+
vi.mocked(requestRuntimeAccessTokenRefreshForServer).mockReset();
|
|
302
318
|
vi.mocked(axios.get).mockReset();
|
|
303
319
|
vi.mocked(axios.get).mockResolvedValueOnce({ status: 200 });
|
|
304
|
-
const handler = instanceRouter.stack.find((layer) => layer.route?.path ===
|
|
305
|
-
expect(handler).toBeTypeOf(
|
|
320
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/ping")?.route?.stack[1]?.handle;
|
|
321
|
+
expect(handler).toBeTypeOf("function");
|
|
306
322
|
const json = vi.fn();
|
|
307
323
|
const status = vi.fn(() => ({ json }));
|
|
308
|
-
const header = vi.fn((name) =>
|
|
324
|
+
const header = vi.fn((name) => name === "X-Scheduler-Base-Url" ? "http://server.local:7380/api" : "");
|
|
309
325
|
await handler?.({ header }, { json, status }, vi.fn());
|
|
310
|
-
expect(getRuntimeAccessToken).toHaveBeenCalledWith(undefined,
|
|
311
|
-
expect(
|
|
312
|
-
|
|
326
|
+
expect(getRuntimeAccessToken).toHaveBeenCalledWith(undefined, "http://server.local:7380");
|
|
327
|
+
expect(requestRuntimeAccessTokenRefreshForServer).not.toHaveBeenCalled();
|
|
328
|
+
expect(axios.get).toHaveBeenCalledWith("http://server.local:7380/api/runtime/ping", {
|
|
329
|
+
headers: { "X-Runtime-Token": "stale-runtime-token-123456" },
|
|
313
330
|
});
|
|
314
331
|
expect(json).toHaveBeenCalledWith({
|
|
315
332
|
ok: true,
|
|
316
|
-
runtimeBridge:
|
|
333
|
+
runtimeBridge: "healthy",
|
|
317
334
|
schedulerReachable: true,
|
|
318
335
|
tokenRefreshed: false,
|
|
319
|
-
schedulerBaseUrl:
|
|
336
|
+
schedulerBaseUrl: "http://server.local:7380",
|
|
337
|
+
});
|
|
338
|
+
expect(status).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
it("refreshes runtime access token when bridge ping has no local scheduler token", async () => {
|
|
341
|
+
const { default: axios } = await import("axios");
|
|
342
|
+
const { requestRuntimeAccessTokenRefreshForServer } = await import("../services/auto-register.js");
|
|
343
|
+
const { getRuntimeAccessToken } = await import("../services/runtime-binding.js");
|
|
344
|
+
const { instanceRouter } = await import("./instance.js");
|
|
345
|
+
vi.mocked(getRuntimeAccessToken).mockReturnValue(undefined);
|
|
346
|
+
vi.mocked(requestRuntimeAccessTokenRefreshForServer).mockResolvedValueOnce({
|
|
347
|
+
success: true,
|
|
348
|
+
runtimeAccessToken: "fresh-runtime-token-123456",
|
|
349
|
+
updated: true,
|
|
350
|
+
});
|
|
351
|
+
vi.mocked(axios.get).mockReset();
|
|
352
|
+
vi.mocked(axios.get).mockResolvedValueOnce({ status: 200 });
|
|
353
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/ping")?.route?.stack[1]?.handle;
|
|
354
|
+
expect(handler).toBeTypeOf("function");
|
|
355
|
+
const json = vi.fn();
|
|
356
|
+
const status = vi.fn(() => ({ json }));
|
|
357
|
+
const header = vi.fn((name) => name === "X-Scheduler-Base-Url" ? "http://server.local:7380/api" : "");
|
|
358
|
+
await handler?.({ header }, { json, status }, vi.fn());
|
|
359
|
+
expect(getRuntimeAccessToken).toHaveBeenCalledWith(undefined, "http://server.local:7380");
|
|
360
|
+
expect(requestRuntimeAccessTokenRefreshForServer).toHaveBeenCalledWith("http://server.local:7380");
|
|
361
|
+
expect(axios.get).toHaveBeenCalledWith("http://server.local:7380/api/runtime/ping", {
|
|
362
|
+
headers: { "X-Runtime-Token": "fresh-runtime-token-123456" },
|
|
363
|
+
});
|
|
364
|
+
expect(json).toHaveBeenCalledWith({
|
|
365
|
+
ok: true,
|
|
366
|
+
runtimeBridge: "healthy",
|
|
367
|
+
schedulerReachable: true,
|
|
368
|
+
tokenRefreshed: true,
|
|
369
|
+
schedulerBaseUrl: "http://server.local:7380",
|
|
320
370
|
});
|
|
321
371
|
expect(status).not.toHaveBeenCalled();
|
|
322
372
|
});
|
|
323
|
-
it(
|
|
324
|
-
const { buildAmbiguousSchedulerBaseUrlResponse } = await import(
|
|
373
|
+
it("explains ambiguous auto-register scheduler targets without picking the first one", async () => {
|
|
374
|
+
const { buildAmbiguousSchedulerBaseUrlResponse } = await import("./instance.js");
|
|
325
375
|
const response = buildAmbiguousSchedulerBaseUrlResponse([
|
|
326
|
-
|
|
327
|
-
|
|
376
|
+
"http://scheduler-a.local:7380",
|
|
377
|
+
"http://scheduler-b.local:7380",
|
|
328
378
|
]);
|
|
329
379
|
expect(response).toEqual({
|
|
330
380
|
ok: false,
|
|
331
|
-
error:
|
|
332
|
-
failureStage:
|
|
333
|
-
runtimeBridge:
|
|
334
|
-
schedulerBaseUrl:
|
|
381
|
+
error: "ambiguous_scheduler_base_url",
|
|
382
|
+
failureStage: "scheduler_ping",
|
|
383
|
+
runtimeBridge: "healthy",
|
|
384
|
+
schedulerBaseUrl: "",
|
|
335
385
|
autoRegisterTargetUrls: [
|
|
336
|
-
|
|
337
|
-
|
|
386
|
+
"http://scheduler-a.local:7380",
|
|
387
|
+
"http://scheduler-b.local:7380",
|
|
338
388
|
],
|
|
339
|
-
hint: expect.stringContaining(
|
|
389
|
+
hint: expect.stringContaining("AWS_RUNTIME_SCHEDULER_BASE_URL"),
|
|
340
390
|
});
|
|
341
391
|
});
|
|
342
|
-
it(
|
|
343
|
-
const { instanceRouter } = await import(
|
|
344
|
-
mockSpawnResult(0,
|
|
345
|
-
const handler = instanceRouter.stack.find((layer) => layer.route?.path ===
|
|
346
|
-
expect(handler).toBeTypeOf(
|
|
392
|
+
it("starts bridge package update in background and returns operation id", async () => {
|
|
393
|
+
const { instanceRouter } = await import("./instance.js");
|
|
394
|
+
mockSpawnResult(0, "updated");
|
|
395
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/update-bridge")?.route?.stack[1]?.handle;
|
|
396
|
+
expect(handler).toBeTypeOf("function");
|
|
347
397
|
const { json, status } = createMockResponse();
|
|
348
|
-
await handler?.({
|
|
398
|
+
await handler?.({
|
|
399
|
+
body: { requestedByUserId: "user-1" },
|
|
400
|
+
get: () => "bridge.local:18081",
|
|
401
|
+
protocol: "http",
|
|
402
|
+
}, { json, status }, vi.fn());
|
|
349
403
|
expect(status).not.toHaveBeenCalled();
|
|
350
404
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
|
351
405
|
ok: true,
|
|
352
|
-
message:
|
|
406
|
+
message: "Bridge 更新已在后台开始,完成后会通过消息中心通知结果。",
|
|
353
407
|
}));
|
|
354
408
|
await new Promise((resolve) => setImmediate(resolve));
|
|
355
|
-
expect(spawnMock).toHaveBeenCalledWith(process.platform ===
|
|
409
|
+
expect(spawnMock).toHaveBeenCalledWith(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", "aws-runtime-bridge@latest"], { shell: false, windowsHide: true });
|
|
356
410
|
});
|
|
357
|
-
it(
|
|
358
|
-
const { instanceRouter } = await import(
|
|
359
|
-
const handler = instanceRouter.stack.find((layer) => layer.route?.path ===
|
|
360
|
-
expect(handler).toBeTypeOf(
|
|
411
|
+
it("rejects background bridge update without requester user id", async () => {
|
|
412
|
+
const { instanceRouter } = await import("./instance.js");
|
|
413
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/update-bridge")?.route?.stack[1]?.handle;
|
|
414
|
+
expect(handler).toBeTypeOf("function");
|
|
361
415
|
const { json, status } = createMockResponse();
|
|
362
416
|
await handler?.({}, { json, status }, vi.fn());
|
|
363
417
|
expect(status).toHaveBeenCalledWith(400);
|
|
364
|
-
expect(json).toHaveBeenCalledWith({
|
|
418
|
+
expect(json).toHaveBeenCalledWith({
|
|
419
|
+
error: "requestedByUserId is required",
|
|
420
|
+
});
|
|
365
421
|
});
|
|
366
|
-
it(
|
|
422
|
+
it("responds before triggering graceful bridge restart", async () => {
|
|
367
423
|
vi.useFakeTimers();
|
|
368
|
-
const { instanceRouter, setGracefulShutdownFn } = await import(
|
|
424
|
+
const { instanceRouter, setGracefulShutdownFn } = await import("./instance.js");
|
|
369
425
|
const gracefulShutdown = vi.fn().mockResolvedValue(undefined);
|
|
370
426
|
setGracefulShutdownFn(gracefulShutdown);
|
|
371
|
-
const handler = instanceRouter.stack.find((layer) => layer.route?.path ===
|
|
372
|
-
expect(handler).toBeTypeOf(
|
|
427
|
+
const handler = instanceRouter.stack.find((layer) => layer.route?.path === "/restart-bridge")?.route?.stack[1]?.handle;
|
|
428
|
+
expect(handler).toBeTypeOf("function");
|
|
373
429
|
const { json, status } = createMockResponse();
|
|
374
|
-
await handler?.({
|
|
430
|
+
await handler?.({
|
|
431
|
+
body: { preserveSessions: true, requestedByUserId: "user-1" },
|
|
432
|
+
get: () => "bridge.local:18081",
|
|
433
|
+
protocol: "http",
|
|
434
|
+
}, { json, status }, vi.fn());
|
|
375
435
|
expect(status).not.toHaveBeenCalled();
|
|
376
436
|
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
|
377
437
|
ok: true,
|
|
378
438
|
preserveSessions: true,
|
|
379
|
-
message:
|
|
439
|
+
message: "Bridge 正在优雅退出;下次启动后会通过消息中心通知结果。若未由守护进程管理,请手动重新启动。",
|
|
380
440
|
}));
|
|
381
441
|
expect(gracefulShutdown).not.toHaveBeenCalled();
|
|
382
442
|
await vi.advanceTimersByTimeAsync(250);
|
|
383
|
-
expect(gracefulShutdown).toHaveBeenCalledWith(
|
|
443
|
+
expect(gracefulShutdown).toHaveBeenCalledWith("PANEL_RESTART", true);
|
|
384
444
|
});
|
|
385
445
|
});
|