aws-runtime-bridge 1.0.1 → 1.0.3
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 +2 -3
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -38
- package/dist/index.js +33 -27
- package/dist/middleware/auth.d.ts +4 -2
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +36 -6
- package/dist/routes/instance.d.ts.map +1 -1
- package/dist/routes/instance.js +52 -34
- package/dist/routes/runtime-binding.d.ts +3 -0
- package/dist/routes/runtime-binding.d.ts.map +1 -0
- package/dist/routes/runtime-binding.js +159 -0
- package/dist/routes/terminal.d.ts.map +1 -1
- package/dist/routes/terminal.js +2 -7
- package/dist/routes/terminal.test.js +0 -4
- package/dist/services/auto-register.d.ts +9 -1
- package/dist/services/auto-register.d.ts.map +1 -1
- package/dist/services/auto-register.js +133 -47
- package/dist/services/aws-client-agent-mcp.d.ts +1 -1
- package/dist/services/aws-client-agent-mcp.d.ts.map +1 -1
- package/dist/services/aws-client-agent-mcp.js +81 -49
- package/dist/services/runtime-binding.d.ts +42 -0
- package/dist/services/runtime-binding.d.ts.map +1 -0
- package/dist/services/runtime-binding.js +257 -0
- package/package.json +1 -1
|
@@ -1,49 +1,52 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync } from
|
|
2
|
-
import path from
|
|
3
|
-
import { fileURLToPath } from
|
|
4
|
-
import { getRuntimeHomeDir, schedulerBaseUrl } from
|
|
5
|
-
import {
|
|
6
|
-
|
|
1
|
+
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { getRuntimeHomeDir, port, schedulerBaseUrl } from "../config.js";
|
|
5
|
+
import { getRuntimeAccessToken, loadRuntimeBinding, } from "./runtime-binding.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
export const AWS_MCP_SERVER_NAME = "aws-mcp";
|
|
7
8
|
const AWS_MCP_ALLOWED_ENV_KEYS = [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
9
|
+
"AWS_INTERNAL_API_KEY",
|
|
10
|
+
"AWS_PROJECT_NAME",
|
|
11
|
+
"AWS_PROMPT",
|
|
12
|
+
"AWS_ROLE_NAME",
|
|
13
|
+
"AWS_SERVER_URL",
|
|
14
|
+
"AWS_MCP_HTTP_URL",
|
|
15
|
+
"AWS_RUNTIME_ACCESS_TOKEN",
|
|
16
|
+
"AWS_RUNTIME_BRIDGE_BASE_URL",
|
|
17
|
+
"AWS_HEARTBEAT_INTERVAL",
|
|
18
|
+
"AWS_HEARTBEAT_TIMEOUT",
|
|
19
|
+
"PATH",
|
|
20
|
+
"HOME",
|
|
21
|
+
"USERPROFILE",
|
|
22
|
+
"TMP",
|
|
23
|
+
"TEMP",
|
|
24
|
+
"SystemRoot",
|
|
25
|
+
"COMSPEC",
|
|
26
|
+
"PATHEXT",
|
|
27
|
+
"WINDIR",
|
|
25
28
|
];
|
|
26
29
|
function getPackageRoot() {
|
|
27
30
|
const currentFile = fileURLToPath(import.meta.url);
|
|
28
|
-
return path.resolve(path.dirname(currentFile),
|
|
31
|
+
return path.resolve(path.dirname(currentFile), "..", "..");
|
|
29
32
|
}
|
|
30
33
|
function getBundledEntryPath() {
|
|
31
|
-
return path.join(getPackageRoot(),
|
|
34
|
+
return path.join(getPackageRoot(), "package", "aws-client-agent-mcp", "dist", "index.js");
|
|
32
35
|
}
|
|
33
36
|
function getReleasedMcpRoot() {
|
|
34
|
-
return path.join(getRuntimeHomeDir(),
|
|
37
|
+
return path.join(getRuntimeHomeDir(), ".aws-bridge", "mcp");
|
|
35
38
|
}
|
|
36
39
|
export function getReleasedMcpDistPath() {
|
|
37
|
-
return path.join(getReleasedMcpRoot(),
|
|
40
|
+
return path.join(getReleasedMcpRoot(), "dist");
|
|
38
41
|
}
|
|
39
42
|
function getReleasedEntryPath() {
|
|
40
|
-
return path.join(getReleasedMcpDistPath(),
|
|
43
|
+
return path.join(getReleasedMcpDistPath(), "index.js");
|
|
41
44
|
}
|
|
42
45
|
export function releaseBundledAwsClientAgentMcp() {
|
|
43
46
|
const bundledDist = path.dirname(getBundledEntryPath());
|
|
44
47
|
const releasedDist = getReleasedMcpDistPath();
|
|
45
|
-
const releasedEntry = path.join(releasedDist,
|
|
46
|
-
if (!existsSync(path.join(bundledDist,
|
|
48
|
+
const releasedEntry = path.join(releasedDist, "index.js");
|
|
49
|
+
if (!existsSync(path.join(bundledDist, "index.js"))) {
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
49
52
|
if (!existsSync(releasedEntry)) {
|
|
@@ -58,67 +61,90 @@ export function releaseBundledAwsClientAgentMcp() {
|
|
|
58
61
|
return releasedEntry;
|
|
59
62
|
}
|
|
60
63
|
function getStringEnv() {
|
|
61
|
-
return Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] ===
|
|
64
|
+
return Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string"));
|
|
62
65
|
}
|
|
63
66
|
function getAllowedAwsMcpEnv(baseEnv) {
|
|
64
67
|
return Object.fromEntries(AWS_MCP_ALLOWED_ENV_KEYS.flatMap((key) => {
|
|
65
68
|
const value = baseEnv[key];
|
|
66
|
-
return typeof value ===
|
|
69
|
+
return typeof value === "string" && value.length > 0
|
|
70
|
+
? [[key, value]]
|
|
71
|
+
: [];
|
|
67
72
|
}));
|
|
68
73
|
}
|
|
69
74
|
function parseAwsClientAgentMcpArgs(raw) {
|
|
70
75
|
try {
|
|
71
76
|
const parsed = JSON.parse(raw);
|
|
72
|
-
if (Array.isArray(parsed) &&
|
|
77
|
+
if (Array.isArray(parsed) &&
|
|
78
|
+
parsed.every((value) => typeof value === "string")) {
|
|
73
79
|
return parsed;
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
catch (error) {
|
|
77
83
|
logger.warn(`[runtime-bridge] AWS_CLIENT_AGENT_MCP_ARGS 不是合法 JSON,已忽略: ${error instanceof Error ? error.message : String(error)}`);
|
|
78
84
|
}
|
|
79
|
-
logger.warn(
|
|
85
|
+
logger.warn("[runtime-bridge] AWS_CLIENT_AGENT_MCP_ARGS 必须是 string[] JSON,已忽略");
|
|
80
86
|
return [];
|
|
81
87
|
}
|
|
82
88
|
function toWebSocketUrl(baseUrl) {
|
|
83
|
-
const url = new URL(
|
|
84
|
-
if (url.protocol ===
|
|
85
|
-
url.protocol =
|
|
89
|
+
const url = new URL("/ws/agent", baseUrl);
|
|
90
|
+
if (url.protocol === "https:") {
|
|
91
|
+
url.protocol = "wss:";
|
|
86
92
|
}
|
|
87
93
|
else {
|
|
88
|
-
url.protocol =
|
|
94
|
+
url.protocol = "ws:";
|
|
89
95
|
}
|
|
90
96
|
return url.toString();
|
|
91
97
|
}
|
|
92
98
|
function toMcpHttpUrl(baseUrl) {
|
|
93
|
-
return new URL(
|
|
99
|
+
return new URL("/mcp/call", baseUrl).toString();
|
|
100
|
+
}
|
|
101
|
+
function resolveSchedulerBaseUrlForMcp() {
|
|
102
|
+
const envSchedulerBaseUrl = String(process.env.AWS_RUNTIME_SCHEDULER_BASE_URL || "").trim();
|
|
103
|
+
if (envSchedulerBaseUrl) {
|
|
104
|
+
return envSchedulerBaseUrl;
|
|
105
|
+
}
|
|
106
|
+
const binding = loadRuntimeBinding();
|
|
107
|
+
const pairedSchedulerBaseUrl = String(binding.schedulerBaseUrl || "").trim();
|
|
108
|
+
if (binding.status === "paired" && pairedSchedulerBaseUrl) {
|
|
109
|
+
return pairedSchedulerBaseUrl;
|
|
110
|
+
}
|
|
111
|
+
return schedulerBaseUrl;
|
|
94
112
|
}
|
|
95
113
|
export function resolveAwsClientAgentMcpCommand(options = {}) {
|
|
96
114
|
return getAwsClientAgentMcpPreparedInfo(options);
|
|
97
115
|
}
|
|
98
116
|
export function getAwsClientAgentMcpPreparedInfo(options = {}) {
|
|
99
|
-
const overrideCommand = String(process.env.AWS_CLIENT_AGENT_MCP_COMMAND ||
|
|
117
|
+
const overrideCommand = String(process.env.AWS_CLIENT_AGENT_MCP_COMMAND || "").trim();
|
|
100
118
|
if (overrideCommand) {
|
|
101
119
|
return {
|
|
102
|
-
source:
|
|
120
|
+
source: "override",
|
|
103
121
|
command: overrideCommand,
|
|
104
|
-
args: process.env.AWS_CLIENT_AGENT_MCP_ARGS
|
|
122
|
+
args: process.env.AWS_CLIENT_AGENT_MCP_ARGS
|
|
123
|
+
? parseAwsClientAgentMcpArgs(process.env.AWS_CLIENT_AGENT_MCP_ARGS)
|
|
124
|
+
: [],
|
|
105
125
|
};
|
|
106
126
|
}
|
|
107
127
|
const exists = options.exists || existsSync;
|
|
108
128
|
const releasedEntry = getReleasedEntryPath();
|
|
109
129
|
if (exists(releasedEntry)) {
|
|
110
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
source: "bundled",
|
|
132
|
+
command: process.execPath,
|
|
133
|
+
args: [releasedEntry],
|
|
134
|
+
};
|
|
111
135
|
}
|
|
112
136
|
const release = options.release || releaseBundledAwsClientAgentMcp;
|
|
113
137
|
const released = release();
|
|
114
138
|
if (released && exists(released)) {
|
|
115
|
-
return { source:
|
|
139
|
+
return { source: "bundled", command: process.execPath, args: [released] };
|
|
116
140
|
}
|
|
117
|
-
return { source:
|
|
141
|
+
return { source: "global", command: "aws-client-agent-mcp", args: [] };
|
|
118
142
|
}
|
|
119
143
|
export function buildAwsMcpServerConfig(input) {
|
|
120
144
|
const env = getStringEnv();
|
|
121
145
|
const command = getAwsClientAgentMcpPreparedInfo();
|
|
146
|
+
const effectiveSchedulerBaseUrl = resolveSchedulerBaseUrlForMcp();
|
|
147
|
+
const issuedRuntimeAccessToken = getRuntimeAccessToken();
|
|
122
148
|
return {
|
|
123
149
|
command: command.command,
|
|
124
150
|
args: command.args,
|
|
@@ -126,15 +152,21 @@ export function buildAwsMcpServerConfig(input) {
|
|
|
126
152
|
...getAllowedAwsMcpEnv(env),
|
|
127
153
|
AWS_AGENT_ID: input.agentId,
|
|
128
154
|
AWS_WORKSPACE_PATH: input.workspacePath,
|
|
129
|
-
AWS_SERVER_URL: env.AWS_SERVER_URL || toWebSocketUrl(
|
|
130
|
-
AWS_MCP_HTTP_URL: env.AWS_MCP_HTTP_URL || toMcpHttpUrl(
|
|
155
|
+
AWS_SERVER_URL: env.AWS_SERVER_URL || toWebSocketUrl(effectiveSchedulerBaseUrl),
|
|
156
|
+
AWS_MCP_HTTP_URL: env.AWS_MCP_HTTP_URL || toMcpHttpUrl(effectiveSchedulerBaseUrl),
|
|
157
|
+
AWS_RUNTIME_BRIDGE_BASE_URL: env.AWS_RUNTIME_BRIDGE_BASE_URL || `http://127.0.0.1:${port}`,
|
|
158
|
+
...(issuedRuntimeAccessToken
|
|
159
|
+
? { AWS_RUNTIME_ACCESS_TOKEN: issuedRuntimeAccessToken }
|
|
160
|
+
: {}),
|
|
131
161
|
},
|
|
132
162
|
};
|
|
133
163
|
}
|
|
134
164
|
export function ensureAwsClientAgentMcpReleased() {
|
|
135
165
|
const prepared = getAwsClientAgentMcpPreparedInfo();
|
|
136
|
-
const commandLine = prepared.args[0]
|
|
137
|
-
|
|
166
|
+
const commandLine = prepared.args[0]
|
|
167
|
+
? `${prepared.command} ${prepared.args.join(" ")}`
|
|
168
|
+
: prepared.command;
|
|
169
|
+
if (prepared.source === "global") {
|
|
138
170
|
logger.warn(`[runtime-bridge] ${AWS_MCP_SERVER_NAME} MCP 未找到 bundled 产物,启动时将回退到 PATH 命令: ${commandLine}`);
|
|
139
171
|
return;
|
|
140
172
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type RuntimeBindingState = {
|
|
2
|
+
status: "unpaired" | "paired";
|
|
3
|
+
instanceId?: string;
|
|
4
|
+
userId?: string;
|
|
5
|
+
schedulerBaseUrl?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Plain runtime access token issued by the scheduler.
|
|
8
|
+
* Stored locally with 0600 permissions so child MCP processes can authenticate
|
|
9
|
+
* scheduler calls without exposing the token in public status APIs.
|
|
10
|
+
*/
|
|
11
|
+
accessToken?: string;
|
|
12
|
+
tokenHash?: string;
|
|
13
|
+
createdAt?: string;
|
|
14
|
+
updatedAt?: string;
|
|
15
|
+
revokedAt?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function getRuntimePairingCode(): string;
|
|
18
|
+
export declare function getRuntimeBindingFilePath(): string;
|
|
19
|
+
export declare function getRuntimeTokenStoreFilePath(): string;
|
|
20
|
+
export declare function buildRuntimeTokenKey(userId: unknown, serverBaseUrl: unknown): string;
|
|
21
|
+
export declare function saveScopedRuntimeAccessToken(input: {
|
|
22
|
+
userId: string;
|
|
23
|
+
serverBaseUrl: string;
|
|
24
|
+
accessToken: string;
|
|
25
|
+
}): string;
|
|
26
|
+
export declare function getScopedRuntimeAccessToken(userId: unknown, serverBaseUrl: unknown): string | undefined;
|
|
27
|
+
export declare function loadRuntimeBinding(): RuntimeBindingState;
|
|
28
|
+
export declare function getRuntimeBindingPublicState(): Omit<RuntimeBindingState, "tokenHash" | "accessToken"> & {
|
|
29
|
+
paired: boolean;
|
|
30
|
+
};
|
|
31
|
+
export declare function hasRuntimeBinding(): boolean;
|
|
32
|
+
export declare function validateRuntimeBindingToken(token: unknown): boolean;
|
|
33
|
+
export declare function getRuntimeAccessToken(userId?: unknown, serverBaseUrl?: unknown): string | undefined;
|
|
34
|
+
export declare function validateRuntimePairingCode(code: unknown): boolean;
|
|
35
|
+
export declare function saveRuntimeBinding(input: {
|
|
36
|
+
accessToken: string;
|
|
37
|
+
instanceId?: string;
|
|
38
|
+
userId?: string;
|
|
39
|
+
schedulerBaseUrl?: string;
|
|
40
|
+
}): RuntimeBindingState;
|
|
41
|
+
export declare function clearRuntimeBinding(): void;
|
|
42
|
+
//# sourceMappingURL=runtime-binding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-binding.d.ts","sourceRoot":"","sources":["../../src/services/runtime-binding.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,UAAU,GAAG,QAAQ,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAoDF,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAED,wBAAgB,yBAAyB,IAAI,MAAM,CAElD;AAED,wBAAgB,4BAA4B,IAAI,MAAM,CAErD;AAoBD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,OAAO,GACrB,MAAM,CAYR;AAsCD,wBAAgB,4BAA4B,CAAC,KAAK,EAAE;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,MAAM,CAWT;AAED,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,OAAO,EACf,aAAa,EAAE,OAAO,GACrB,MAAM,GAAG,SAAS,CAUpB;AAcD,wBAAgB,kBAAkB,IAAI,mBAAmB,CAWxD;AAED,wBAAgB,4BAA4B,IAAI,IAAI,CAClD,mBAAmB,EACnB,WAAW,GAAG,aAAa,CAC5B,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAWtB;AAED,wBAAgB,iBAAiB,IAAI,OAAO,CAG3C;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAcnE;AAED,wBAAgB,qBAAqB,CACnC,MAAM,CAAC,EAAE,OAAO,EAChB,aAAa,CAAC,EAAE,OAAO,GACtB,MAAM,GAAG,SAAS,CAWpB;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAEjE;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,GAAG,mBAAmB,CAuCtB;AAED,wBAAgB,mBAAmB,IAAI,IAAI,CAkB1C"}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getRuntimeHomeDir } from "../config.js";
|
|
5
|
+
import { createLogger } from "../utils/logger.js";
|
|
6
|
+
const log = createLogger("runtime-binding");
|
|
7
|
+
const TOKEN_HASH_ALGORITHM = "sha256";
|
|
8
|
+
const pairingCode = generatePairingCode();
|
|
9
|
+
function generatePairingCode() {
|
|
10
|
+
const value = crypto.randomInt(0, 1_000_000);
|
|
11
|
+
return String(value)
|
|
12
|
+
.padStart(6, "0")
|
|
13
|
+
.replace(/(\d{3})(\d{3})/, "$1-$2");
|
|
14
|
+
}
|
|
15
|
+
function bindingDir() {
|
|
16
|
+
return path.join(getRuntimeHomeDir(), ".agentswork", "runtime-bridge");
|
|
17
|
+
}
|
|
18
|
+
function bindingFile() {
|
|
19
|
+
return path.join(bindingDir(), "binding.json");
|
|
20
|
+
}
|
|
21
|
+
function tokenStoreFile() {
|
|
22
|
+
return path.join(bindingDir(), "runtime-tokens.properties");
|
|
23
|
+
}
|
|
24
|
+
function hashToken(token) {
|
|
25
|
+
return crypto
|
|
26
|
+
.createHash(TOKEN_HASH_ALGORITHM)
|
|
27
|
+
.update(token, "utf8")
|
|
28
|
+
.digest("hex");
|
|
29
|
+
}
|
|
30
|
+
function safeEqual(a, b) {
|
|
31
|
+
const left = Buffer.from(a, "hex");
|
|
32
|
+
const right = Buffer.from(b, "hex");
|
|
33
|
+
if (left.length !== right.length) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return crypto.timingSafeEqual(left, right);
|
|
37
|
+
}
|
|
38
|
+
function safeTextEqual(a, b) {
|
|
39
|
+
const left = Buffer.from(a, "utf8");
|
|
40
|
+
const right = Buffer.from(b, "utf8");
|
|
41
|
+
if (left.length !== right.length) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return crypto.timingSafeEqual(left, right);
|
|
45
|
+
}
|
|
46
|
+
function normalizeToken(token) {
|
|
47
|
+
return String(token || "").trim();
|
|
48
|
+
}
|
|
49
|
+
export function getRuntimePairingCode() {
|
|
50
|
+
return pairingCode;
|
|
51
|
+
}
|
|
52
|
+
export function getRuntimeBindingFilePath() {
|
|
53
|
+
return bindingFile();
|
|
54
|
+
}
|
|
55
|
+
export function getRuntimeTokenStoreFilePath() {
|
|
56
|
+
return tokenStoreFile();
|
|
57
|
+
}
|
|
58
|
+
function normalizeServerIp(serverBaseUrl) {
|
|
59
|
+
const raw = String(serverBaseUrl || "").trim();
|
|
60
|
+
if (!raw) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return new URL(raw).hostname.toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return raw
|
|
68
|
+
.replace(/^wss?:\/\//i, "")
|
|
69
|
+
.replace(/^https?:\/\//i, "")
|
|
70
|
+
.split("/")[0]
|
|
71
|
+
.split(":")[0]
|
|
72
|
+
.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function buildRuntimeTokenKey(userId, serverBaseUrl) {
|
|
76
|
+
const normalizedUserId = String(userId || "").trim();
|
|
77
|
+
const serverIp = normalizeServerIp(serverBaseUrl);
|
|
78
|
+
if (!normalizedUserId || !serverIp) {
|
|
79
|
+
throw new Error("userId and serverBaseUrl are required to build runtime token key");
|
|
80
|
+
}
|
|
81
|
+
return crypto
|
|
82
|
+
.createHash("md5")
|
|
83
|
+
.update(`${normalizedUserId}:${serverIp}`, "utf8")
|
|
84
|
+
.digest("hex");
|
|
85
|
+
}
|
|
86
|
+
function loadRuntimeTokenStore() {
|
|
87
|
+
const result = new Map();
|
|
88
|
+
try {
|
|
89
|
+
const content = fs.readFileSync(tokenStoreFile(), "utf8");
|
|
90
|
+
for (const line of content.split(/\r?\n/)) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const separatorIndex = trimmed.indexOf("=");
|
|
96
|
+
if (separatorIndex <= 0) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
100
|
+
const token = trimmed.slice(separatorIndex + 1).trim();
|
|
101
|
+
if (key && token) {
|
|
102
|
+
result.set(key, token);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Missing token store is valid for an unpaired bridge.
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function saveRuntimeTokenStore(tokens) {
|
|
112
|
+
fs.mkdirSync(bindingDir(), { recursive: true });
|
|
113
|
+
const lines = [...tokens.entries()]
|
|
114
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
115
|
+
.map(([key, token]) => `${key}=${token}`);
|
|
116
|
+
fs.writeFileSync(tokenStoreFile(), `${lines.join("\n")}\n`, {
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
mode: 0o600,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export function saveScopedRuntimeAccessToken(input) {
|
|
122
|
+
const accessToken = normalizeToken(input.accessToken);
|
|
123
|
+
if (accessToken.length < 16) {
|
|
124
|
+
throw new Error("accessToken must be at least 16 characters");
|
|
125
|
+
}
|
|
126
|
+
const key = buildRuntimeTokenKey(input.userId, input.serverBaseUrl);
|
|
127
|
+
const tokens = loadRuntimeTokenStore();
|
|
128
|
+
tokens.set(key, accessToken);
|
|
129
|
+
saveRuntimeTokenStore(tokens);
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
export function getScopedRuntimeAccessToken(userId, serverBaseUrl) {
|
|
133
|
+
if (!userId || !serverBaseUrl) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const key = buildRuntimeTokenKey(userId, serverBaseUrl);
|
|
138
|
+
return loadRuntimeTokenStore().get(key);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function validateRuntimeTokenStore(token) {
|
|
145
|
+
if (!token) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
for (const storedToken of loadRuntimeTokenStore().values()) {
|
|
149
|
+
if (safeTextEqual(token, storedToken)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
export function loadRuntimeBinding() {
|
|
156
|
+
try {
|
|
157
|
+
const raw = fs.readFileSync(bindingFile(), "utf8");
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
if (parsed?.status === "paired" && parsed.tokenHash) {
|
|
160
|
+
return parsed;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Missing or invalid binding file means the bridge is unpaired.
|
|
165
|
+
}
|
|
166
|
+
return { status: "unpaired" };
|
|
167
|
+
}
|
|
168
|
+
export function getRuntimeBindingPublicState() {
|
|
169
|
+
const state = loadRuntimeBinding();
|
|
170
|
+
const { tokenHash: _tokenHash, accessToken: _accessToken, ...publicState } = state;
|
|
171
|
+
return {
|
|
172
|
+
...publicState,
|
|
173
|
+
paired: state.status === "paired",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function hasRuntimeBinding() {
|
|
177
|
+
const state = loadRuntimeBinding();
|
|
178
|
+
return state.status === "paired" && Boolean(state.tokenHash);
|
|
179
|
+
}
|
|
180
|
+
export function validateRuntimeBindingToken(token) {
|
|
181
|
+
const candidate = normalizeToken(token);
|
|
182
|
+
if (!candidate) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const state = loadRuntimeBinding();
|
|
186
|
+
if (state.status === "paired" && state.tokenHash) {
|
|
187
|
+
if (safeEqual(hashToken(candidate), state.tokenHash)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return validateRuntimeTokenStore(candidate);
|
|
192
|
+
}
|
|
193
|
+
export function getRuntimeAccessToken(userId, serverBaseUrl) {
|
|
194
|
+
const scopedToken = getScopedRuntimeAccessToken(userId, serverBaseUrl);
|
|
195
|
+
if (scopedToken) {
|
|
196
|
+
return scopedToken;
|
|
197
|
+
}
|
|
198
|
+
const state = loadRuntimeBinding();
|
|
199
|
+
if (state.status !== "paired") {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
return normalizeToken(state.accessToken) || undefined;
|
|
203
|
+
}
|
|
204
|
+
export function validateRuntimePairingCode(code) {
|
|
205
|
+
return String(code || "").trim() === pairingCode;
|
|
206
|
+
}
|
|
207
|
+
export function saveRuntimeBinding(input) {
|
|
208
|
+
const accessToken = normalizeToken(input.accessToken);
|
|
209
|
+
if (accessToken.length < 16) {
|
|
210
|
+
throw new Error("accessToken must be at least 16 characters");
|
|
211
|
+
}
|
|
212
|
+
const previous = loadRuntimeBinding();
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
const next = {
|
|
215
|
+
status: "paired",
|
|
216
|
+
instanceId: input.instanceId
|
|
217
|
+
? String(input.instanceId)
|
|
218
|
+
: previous.instanceId,
|
|
219
|
+
userId: input.userId ? String(input.userId) : previous.userId,
|
|
220
|
+
schedulerBaseUrl: input.schedulerBaseUrl
|
|
221
|
+
? String(input.schedulerBaseUrl)
|
|
222
|
+
: previous.schedulerBaseUrl,
|
|
223
|
+
accessToken,
|
|
224
|
+
tokenHash: hashToken(accessToken),
|
|
225
|
+
createdAt: previous.createdAt || now,
|
|
226
|
+
updatedAt: now,
|
|
227
|
+
};
|
|
228
|
+
fs.mkdirSync(bindingDir(), { recursive: true });
|
|
229
|
+
fs.writeFileSync(bindingFile(), `${JSON.stringify(next, null, 2)}\n`, {
|
|
230
|
+
encoding: "utf8",
|
|
231
|
+
mode: 0o600,
|
|
232
|
+
});
|
|
233
|
+
if (next.userId && next.schedulerBaseUrl) {
|
|
234
|
+
const key = saveScopedRuntimeAccessToken({
|
|
235
|
+
userId: next.userId,
|
|
236
|
+
serverBaseUrl: next.schedulerBaseUrl,
|
|
237
|
+
accessToken,
|
|
238
|
+
});
|
|
239
|
+
log.info(`[runtime-bridge] scoped runtime token saved: key=${key}`);
|
|
240
|
+
}
|
|
241
|
+
log.info(`[runtime-bridge] runtime binding saved: ${bindingFile()}`);
|
|
242
|
+
return next;
|
|
243
|
+
}
|
|
244
|
+
export function clearRuntimeBinding() {
|
|
245
|
+
const current = loadRuntimeBinding();
|
|
246
|
+
const revokedState = {
|
|
247
|
+
...current,
|
|
248
|
+
status: "unpaired",
|
|
249
|
+
accessToken: undefined,
|
|
250
|
+
tokenHash: undefined,
|
|
251
|
+
revokedAt: new Date().toISOString(),
|
|
252
|
+
updatedAt: new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
fs.mkdirSync(bindingDir(), { recursive: true });
|
|
255
|
+
fs.writeFileSync(bindingFile(), `${JSON.stringify(revokedState, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
256
|
+
log.info("[runtime-bridge] runtime binding cleared");
|
|
257
|
+
}
|