@iinm/plain-agent 1.7.24 → 1.8.0
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 +1 -1
- package/config/agents.predefined/sandbox-configurator.md +1 -3
- package/config/config.predefined.json +23 -0
- package/package.json +2 -8
- package/src/agent.d.ts +2 -2
- package/src/config.d.ts +1 -1
- package/src/main.mjs +3 -2
- package/src/mcpClient.mjs +351 -0
- package/src/mcpClient.test.mockServer.mjs +83 -0
- package/src/{mcp.mjs → mcpIntegration.mjs} +15 -56
- package/src/modelDefinition.d.ts +1 -1
- package/src/prompt.mjs +5 -2
- package/src/providers/anthropic.mjs +16 -26
- package/src/providers/bedrock.mjs +14 -24
- package/src/providers/openaiCompatible.mjs +16 -26
- package/src/providers/platform/awsSigV4.mjs +161 -0
- package/src/tools/patchFile.mjs +26 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A lightweight CLI-based coding agent.
|
|
|
6
6
|
- **Compact system prompt** — Under 3.5 KB, keeping per-request overhead and cost low ([src/prompt.mjs](https://github.com/iinm/plain-agent/blob/main/src/prompt.mjs)).
|
|
7
7
|
- **Fine-grained approval rules** — Auto-approve commands by name, arguments,
|
|
8
8
|
and file paths using regex patterns
|
|
9
|
-
([
|
|
9
|
+
([config.predefined.json#autoApproval](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)).
|
|
10
10
|
- **Path validation** — Restricts access to the working directory.
|
|
11
11
|
Git-ignored and untracked files require explicit approval.
|
|
12
12
|
- **Sandboxed execution** — Run the agent's shell commands inside a Docker
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: Analyzes the project and generates sandbox configuration files (run.sh, setup.sh) tailored to the project's needs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
You are a sandbox
|
|
5
|
+
You are a sandbox configurator. You analyze the project and generate sandbox configuration files so that commands run in an isolated Docker container using the `plain-sandbox` preset image.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
@@ -11,8 +11,6 @@ You create the following files:
|
|
|
11
11
|
- `.plain-agent/sandbox/run.sh` — Wrapper script for `plain-sandbox` with project-specific options
|
|
12
12
|
- `.plain-agent/setup.sh` — Initial setup script for both sandbox and host
|
|
13
13
|
|
|
14
|
-
You also show an example `sandbox` config for `.plain-agent/config.json`, but you **never modify** config.json directly.
|
|
15
|
-
|
|
16
14
|
## Step 1: Analyze the Project
|
|
17
15
|
|
|
18
16
|
Before generating anything, analyze the project to determine:
|
|
@@ -1050,6 +1050,29 @@
|
|
|
1050
1050
|
}
|
|
1051
1051
|
}
|
|
1052
1052
|
},
|
|
1053
|
+
{
|
|
1054
|
+
"name": "deepseek-v4-pro",
|
|
1055
|
+
"variant": "fireworks",
|
|
1056
|
+
"platform": {
|
|
1057
|
+
"name": "openai-compatible",
|
|
1058
|
+
"variant": "fireworks"
|
|
1059
|
+
},
|
|
1060
|
+
"model": {
|
|
1061
|
+
"format": "openai-messages",
|
|
1062
|
+
"config": {
|
|
1063
|
+
"model": "accounts/fireworks/models/deepseek-v4-pro"
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
"cost": {
|
|
1067
|
+
"currency": "USD",
|
|
1068
|
+
"unit": "1M",
|
|
1069
|
+
"costs": {
|
|
1070
|
+
"prompt_tokens": 1.74,
|
|
1071
|
+
"prompt_tokens_details.cached_tokens": -1.6,
|
|
1072
|
+
"completion_tokens": 3.48
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1053
1076
|
|
|
1054
1077
|
{
|
|
1055
1078
|
"name": "minimax-m2.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "A lightweight CLI-based coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,12 +33,6 @@
|
|
|
33
33
|
"fix": "npx @biomejs/biome check --fix"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aws-crypto/sha256-js": "^5.2.0",
|
|
37
|
-
"@aws-sdk/credential-providers": "^3.1030.0",
|
|
38
|
-
"@cfworker/json-schema": "^4.1.1",
|
|
39
|
-
"@modelcontextprotocol/client": "^2.0.0-alpha.2",
|
|
40
|
-
"@smithy/protocol-http": "^5.3.13",
|
|
41
|
-
"@smithy/signature-v4": "^5.3.13",
|
|
42
36
|
"diff": "^8.0.4",
|
|
43
37
|
"js-yaml": "^4.1.1"
|
|
44
38
|
},
|
|
@@ -46,6 +40,6 @@
|
|
|
46
40
|
"@biomejs/biome": "^2.4.12",
|
|
47
41
|
"@types/js-yaml": "^4.0.9",
|
|
48
42
|
"@types/node": "^22.19.17",
|
|
49
|
-
"typescript": "^
|
|
43
|
+
"typescript": "^6.0.2"
|
|
50
44
|
}
|
|
51
45
|
}
|
package/src/agent.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { AgentRole } from "./context/loadAgentRoles.mjs";
|
|
3
|
+
import type { CostConfig, CostSummary } from "./costTracker.mjs";
|
|
2
4
|
import type {
|
|
3
5
|
CallModel,
|
|
4
6
|
Message,
|
|
@@ -8,8 +10,6 @@ import type {
|
|
|
8
10
|
ProviderTokenUsage,
|
|
9
11
|
} from "./model";
|
|
10
12
|
import type { Tool, ToolUseApprover } from "./tool";
|
|
11
|
-
import type { AgentRole } from "./context/loadAgentRoles.mjs";
|
|
12
|
-
import type { CostSummary, CostConfig } from "./costTracker.mjs";
|
|
13
13
|
|
|
14
14
|
export type Agent = {
|
|
15
15
|
userEventEmitter: UserEventEmitter;
|
package/src/config.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
|
|
1
2
|
import { ModelDefinition, PlatformConfig } from "./modelDefinition";
|
|
2
3
|
import { ToolUsePattern } from "./tool";
|
|
3
4
|
import { AskURLToolOptions } from "./tools/askURL.mjs";
|
|
4
5
|
import { AskWebToolOptions } from "./tools/askWeb.mjs";
|
|
5
6
|
import { ExecCommandSanboxConfig } from "./tools/execCommand";
|
|
6
|
-
import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
|
|
7
7
|
import { VoiceInputConfig } from "./voiceInput.mjs";
|
|
8
8
|
|
|
9
9
|
export type AppConfig = {
|
package/src/main.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { loadAppConfig } from "./config.mjs";
|
|
|
16
16
|
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
17
17
|
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
18
18
|
import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
|
|
19
|
-
import { setupMCPServer } from "./
|
|
19
|
+
import { setupMCPServer } from "./mcpIntegration.mjs";
|
|
20
20
|
import { createModelCaller } from "./modelCaller.mjs";
|
|
21
21
|
import { createPrompt } from "./prompt.mjs";
|
|
22
22
|
import { createAskURLTool } from "./tools/askURL.mjs";
|
|
@@ -164,9 +164,10 @@ if (cliArgs.subcommand.type === "cost") {
|
|
|
164
164
|
const prompt = createPrompt({
|
|
165
165
|
username: USER_NAME,
|
|
166
166
|
modelName: modelNameWithVariant,
|
|
167
|
+
workingDir: process.cwd(),
|
|
168
|
+
today: new Date().toISOString().split("T")[0],
|
|
167
169
|
sessionId,
|
|
168
170
|
tmuxSessionId,
|
|
169
|
-
workingDir: process.cwd(),
|
|
170
171
|
projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
|
|
171
172
|
agentRoles,
|
|
172
173
|
skills: Array.from(prompts.values()).filter((p) => p.isSkill),
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} CreateMCPClientOptions
|
|
7
|
+
* @property {string} command
|
|
8
|
+
* @property {string[]} [args]
|
|
9
|
+
* @property {Record<string, string>} [env]
|
|
10
|
+
* @property {"inherit" | "ignore" | "pipe" | string} [stderr]
|
|
11
|
+
* @property {string} [protocolVersion]
|
|
12
|
+
* @property {{ name: string, version: string }} [clientInfo]
|
|
13
|
+
* @property {Record<string, unknown>} [capabilities]
|
|
14
|
+
* @property {(method: string, params?: unknown) => void} [onNotification]
|
|
15
|
+
* Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Spawn an MCP server process and return an initialized client.
|
|
20
|
+
* @param {CreateMCPClientOptions} options
|
|
21
|
+
* @returns {Promise<MCPClient>}
|
|
22
|
+
*/
|
|
23
|
+
export async function createMCPClient(options) {
|
|
24
|
+
const transport = new StdioTransport(options.command, options.args, {
|
|
25
|
+
env: options.env,
|
|
26
|
+
stderr: options.stderr,
|
|
27
|
+
onNotification: options.onNotification,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const client = new MCPClient(transport);
|
|
31
|
+
try {
|
|
32
|
+
await client.initialize({
|
|
33
|
+
protocolVersion: options.protocolVersion,
|
|
34
|
+
clientInfo: options.clientInfo,
|
|
35
|
+
capabilities: options.capabilities,
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
await client.close().catch(() => {});
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
return client;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* MCP protocol client.
|
|
46
|
+
* Delegates transport concerns to a transport object.
|
|
47
|
+
*/
|
|
48
|
+
export class MCPClient {
|
|
49
|
+
/** @type {StdioTransport} */
|
|
50
|
+
#transport;
|
|
51
|
+
#closed = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {StdioTransport} transport
|
|
55
|
+
*/
|
|
56
|
+
constructor(transport) {
|
|
57
|
+
this.#transport = transport;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Object} [options]
|
|
62
|
+
* @param {string} [options.protocolVersion]
|
|
63
|
+
* @param {{ name: string, version: string }} [options.clientInfo]
|
|
64
|
+
* @param {Record<string, unknown>} [options.capabilities]
|
|
65
|
+
* @returns {Promise<any>}
|
|
66
|
+
*/
|
|
67
|
+
async initialize(options = {}) {
|
|
68
|
+
if (this.#closed) {
|
|
69
|
+
throw new Error("MCP client is closed");
|
|
70
|
+
}
|
|
71
|
+
const result = await this.#transport.request("initialize", {
|
|
72
|
+
protocolVersion: options.protocolVersion ?? "2025-03-26",
|
|
73
|
+
capabilities: options.capabilities ?? {},
|
|
74
|
+
clientInfo: options.clientInfo ?? {
|
|
75
|
+
name: "unknown",
|
|
76
|
+
version: "0.0.0",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
this.#transport.notify("notifications/initialized");
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @returns {Promise<{ tools: Array<{ name: string, description?: string, inputSchema: Record<string, unknown> }> }>}
|
|
85
|
+
*/
|
|
86
|
+
async listTools() {
|
|
87
|
+
if (this.#closed) {
|
|
88
|
+
throw new Error("MCP client is closed");
|
|
89
|
+
}
|
|
90
|
+
return this.#transport.request("tools/list", {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {{ name: string, arguments?: Record<string, unknown> }} params
|
|
95
|
+
* @returns {Promise<{ content?: Array<{ type: string, text?: string, data?: string, mimeType?: string }>, isError?: boolean }>}
|
|
96
|
+
*/
|
|
97
|
+
async callTool(params) {
|
|
98
|
+
if (this.#closed) {
|
|
99
|
+
throw new Error("MCP client is closed");
|
|
100
|
+
}
|
|
101
|
+
return this.#transport.request("tools/call", params);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close() {
|
|
105
|
+
if (this.#closed) return;
|
|
106
|
+
this.#closed = true;
|
|
107
|
+
await this.#transport.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} StdioTransportOptions
|
|
113
|
+
* @property {Record<string, string>} [env]
|
|
114
|
+
* @property {"inherit" | "ignore" | "pipe" | string} [stderr]
|
|
115
|
+
* @property {(method: string, params?: unknown) => void} [onNotification]
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* JSON-RPC 2.0 transport over stdio.
|
|
120
|
+
* Manages the child process lifecycle and message passing.
|
|
121
|
+
*/
|
|
122
|
+
export class StdioTransport {
|
|
123
|
+
/** @type {import("node:child_process").ChildProcess} */
|
|
124
|
+
#process;
|
|
125
|
+
/** @type {import("node:readline").Interface} */
|
|
126
|
+
#rl;
|
|
127
|
+
#nextId = 1;
|
|
128
|
+
/** @type {Map<number, { resolve: (value: any) => void, reject: (reason: any) => void, timer: NodeJS.Timeout }>} */
|
|
129
|
+
#pendingRequests = new Map();
|
|
130
|
+
#closed = false;
|
|
131
|
+
/** @type {Error | undefined} */
|
|
132
|
+
#earlyExitError;
|
|
133
|
+
/** @type {((line: string) => void)} */
|
|
134
|
+
#onLine;
|
|
135
|
+
/** @type {((code: number | null) => void)} */
|
|
136
|
+
#onClose;
|
|
137
|
+
/** @type {((err: Error) => void)} */
|
|
138
|
+
#onError;
|
|
139
|
+
/** @type {number | undefined} */
|
|
140
|
+
#stderrFd;
|
|
141
|
+
/** @type {((method: string, params?: unknown) => void) | undefined} */
|
|
142
|
+
// Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
|
|
143
|
+
#onNotification;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} command
|
|
147
|
+
* @param {string[]} [args]
|
|
148
|
+
* @param {StdioTransportOptions} [options]
|
|
149
|
+
*/
|
|
150
|
+
constructor(command, args, options = {}) {
|
|
151
|
+
const defaultEnv = {
|
|
152
|
+
PWD: process.env.PWD || "",
|
|
153
|
+
PATH: process.env.PATH || "",
|
|
154
|
+
HOME: process.env.HOME || "",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** @type {"inherit" | "ignore" | "pipe" | number} */
|
|
158
|
+
let stderrValue = "ignore";
|
|
159
|
+
if (
|
|
160
|
+
options.stderr === "inherit" ||
|
|
161
|
+
options.stderr === "ignore" ||
|
|
162
|
+
options.stderr === "pipe"
|
|
163
|
+
) {
|
|
164
|
+
stderrValue = options.stderr;
|
|
165
|
+
} else if (typeof options.stderr === "string") {
|
|
166
|
+
this.#stderrFd = openSync(options.stderr, "a");
|
|
167
|
+
stderrValue = this.#stderrFd;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const childProcess = spawn(command, args || [], {
|
|
171
|
+
env: { ...defaultEnv, ...options.env },
|
|
172
|
+
stdio: /** @type {import("node:child_process").StdioOptions} */ ([
|
|
173
|
+
"pipe",
|
|
174
|
+
"pipe",
|
|
175
|
+
stderrValue,
|
|
176
|
+
]),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.#process = childProcess;
|
|
180
|
+
this.#onNotification = options.onNotification;
|
|
181
|
+
|
|
182
|
+
if (!childProcess.stdout) {
|
|
183
|
+
throw new Error("MCP server stdout is not available");
|
|
184
|
+
}
|
|
185
|
+
this.#rl = createInterface({ input: childProcess.stdout });
|
|
186
|
+
|
|
187
|
+
this.#onLine = (line) => this.#handleLine(line);
|
|
188
|
+
this.#rl.on("line", this.#onLine);
|
|
189
|
+
|
|
190
|
+
this.#onClose = (code) => this.#handleProcessClose(code);
|
|
191
|
+
childProcess.on("close", this.#onClose);
|
|
192
|
+
|
|
193
|
+
this.#onError = (err) => this.#handleProcessError(err);
|
|
194
|
+
childProcess.on("error", this.#onError);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
199
|
+
*/
|
|
200
|
+
get process() {
|
|
201
|
+
return this.#process;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {string} line
|
|
206
|
+
*/
|
|
207
|
+
#handleLine(line) {
|
|
208
|
+
if (!line.trim()) return;
|
|
209
|
+
try {
|
|
210
|
+
const msg = JSON.parse(line);
|
|
211
|
+
if (!("id" in msg)) {
|
|
212
|
+
this.#onNotification?.(msg.method, msg.params);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (this.#pendingRequests.has(msg.id)) {
|
|
216
|
+
const pending = this.#pendingRequests.get(msg.id);
|
|
217
|
+
if (!pending) return;
|
|
218
|
+
this.#pendingRequests.delete(msg.id);
|
|
219
|
+
clearTimeout(pending.timer);
|
|
220
|
+
if (msg.error) {
|
|
221
|
+
pending.reject(
|
|
222
|
+
new Error(msg.error.message || JSON.stringify(msg.error)),
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
pending.resolve(msg.result);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Ignore non-JSON lines
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {number | null} code
|
|
235
|
+
*/
|
|
236
|
+
#handleProcessClose(code) {
|
|
237
|
+
const err = new Error(`MCP server exited with code ${code}`);
|
|
238
|
+
this.#earlyExitError = err;
|
|
239
|
+
this.#rejectAllPending(err);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {Error} err
|
|
244
|
+
*/
|
|
245
|
+
#handleProcessError(err) {
|
|
246
|
+
this.#earlyExitError = err;
|
|
247
|
+
this.#rejectAllPending(err);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {Error} error
|
|
252
|
+
*/
|
|
253
|
+
#rejectAllPending(error) {
|
|
254
|
+
for (const [, { reject, timer }] of this.#pendingRequests) {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
this.#pendingRequests.clear();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} method
|
|
263
|
+
* @param {Record<string, unknown>} [params]
|
|
264
|
+
* @param {number} [timeoutMs]
|
|
265
|
+
* @returns {Promise<any>}
|
|
266
|
+
*/
|
|
267
|
+
request(method, params, timeoutMs = 30000) {
|
|
268
|
+
if (this.#closed) {
|
|
269
|
+
return Promise.reject(new Error("MCP client is closed"));
|
|
270
|
+
}
|
|
271
|
+
if (this.#earlyExitError) {
|
|
272
|
+
return Promise.reject(this.#earlyExitError);
|
|
273
|
+
}
|
|
274
|
+
const id = this.#nextId++;
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const timer = setTimeout(() => {
|
|
277
|
+
this.#pendingRequests.delete(id);
|
|
278
|
+
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
|
|
279
|
+
}, timeoutMs);
|
|
280
|
+
|
|
281
|
+
this.#pendingRequests.set(id, { resolve, reject, timer });
|
|
282
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
283
|
+
this.#process.stdin?.write(`${msg}\n`, (err) => {
|
|
284
|
+
if (err) {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
this.#pendingRequests.delete(id);
|
|
287
|
+
reject(err);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} method
|
|
295
|
+
* @param {Record<string, unknown>} [params]
|
|
296
|
+
*/
|
|
297
|
+
notify(method, params) {
|
|
298
|
+
if (this.#closed || this.#earlyExitError) return;
|
|
299
|
+
const msg = JSON.stringify(
|
|
300
|
+
params ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", method },
|
|
301
|
+
);
|
|
302
|
+
this.#process.stdin?.write(`${msg}\n`, () => {
|
|
303
|
+
// Ignore write errors in notifications
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {NodeJS.Signals} [signal]
|
|
309
|
+
* @param {number} [timeoutMs]
|
|
310
|
+
* @returns {Promise<void>}
|
|
311
|
+
*/
|
|
312
|
+
async close(signal = "SIGTERM", timeoutMs = 5000) {
|
|
313
|
+
if (this.#closed) return;
|
|
314
|
+
this.#closed = true;
|
|
315
|
+
this.#rejectAllPending(new Error("MCP client is closed"));
|
|
316
|
+
this.#rl.off("line", this.#onLine);
|
|
317
|
+
this.#rl.close();
|
|
318
|
+
this.#process.stdin?.end();
|
|
319
|
+
this.#process.off("close", this.#onClose);
|
|
320
|
+
this.#process.off("error", this.#onError);
|
|
321
|
+
|
|
322
|
+
const closePromise = new Promise((resolve) => {
|
|
323
|
+
if (
|
|
324
|
+
this.#process.exitCode !== null ||
|
|
325
|
+
this.#process.signalCode !== null
|
|
326
|
+
) {
|
|
327
|
+
resolve(undefined);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
this.#process.kill("SIGKILL");
|
|
332
|
+
resolve(undefined);
|
|
333
|
+
}, timeoutMs);
|
|
334
|
+
this.#process.once("close", () => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
resolve(undefined);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
this.#process.kill(signal);
|
|
341
|
+
await closePromise;
|
|
342
|
+
|
|
343
|
+
if (this.#stderrFd !== undefined) {
|
|
344
|
+
try {
|
|
345
|
+
closeSync(this.#stderrFd);
|
|
346
|
+
} catch {
|
|
347
|
+
// ignore
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock MCP server for testing.
|
|
3
|
+
* Speaks JSON-RPC 2.0 over stdio.
|
|
4
|
+
* Handles: initialize, notifications/initialized, tools/list, tools/call.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
|
|
9
|
+
const rl = createInterface({ input: process.stdin });
|
|
10
|
+
|
|
11
|
+
rl.on("line", (line) => {
|
|
12
|
+
let msg;
|
|
13
|
+
try {
|
|
14
|
+
msg = JSON.parse(line);
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (msg.method === "initialize") {
|
|
20
|
+
respond(msg.id, {
|
|
21
|
+
protocolVersion: "2025-03-26",
|
|
22
|
+
capabilities: {},
|
|
23
|
+
serverInfo: { name: "mock", version: "0.0.0" },
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (msg.method === "notifications/initialized") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (msg.method === "tools/list") {
|
|
33
|
+
respond(msg.id, {
|
|
34
|
+
tools: [
|
|
35
|
+
{
|
|
36
|
+
name: "echo",
|
|
37
|
+
description: "Echo tool",
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { text: { type: "string" } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "add",
|
|
45
|
+
description: "Add numbers",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
a: { type: "number" },
|
|
50
|
+
b: { type: "number" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (msg.method === "tools/call") {
|
|
60
|
+
const { name, arguments: args } = msg.params;
|
|
61
|
+
if (name === "echo") {
|
|
62
|
+
respond(msg.id, { content: [{ type: "text", text: args.text }] });
|
|
63
|
+
} else if (name === "add") {
|
|
64
|
+
respond(msg.id, {
|
|
65
|
+
content: [{ type: "text", text: String(args.a + args.b) }],
|
|
66
|
+
});
|
|
67
|
+
} else if (name === "error_tool") {
|
|
68
|
+
respondError(msg.id, -1, "tool failed");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** @param {number} id @param {unknown} result */
|
|
74
|
+
function respond(id, result) {
|
|
75
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {number} id @param {number} code @param {string} message */
|
|
79
|
+
function respondError(id, code, message) {
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { Client } from "@modelcontextprotocol/client";
|
|
3
2
|
* @import { StructuredToolResultContent, Tool, ToolImplementation } from "./tool";
|
|
4
3
|
* @import { MCPServerConfig } from "./config";
|
|
5
4
|
*/
|
|
6
5
|
|
|
7
|
-
import { mkdir
|
|
6
|
+
import { mkdir } from "node:fs/promises";
|
|
8
7
|
import path from "node:path";
|
|
9
8
|
import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
|
|
9
|
+
import { createMCPClient } from "./mcpClient.mjs";
|
|
10
10
|
import { writeTmpFile } from "./tmpfile.mjs";
|
|
11
11
|
import { noThrow } from "./utils/noThrow.mjs";
|
|
12
12
|
|
|
13
|
+
/** @typedef {import("./mcpClient.mjs").MCPClient} MCPClient */
|
|
14
|
+
|
|
13
15
|
const OUTPUT_MAX_LENGTH = 1024 * 8;
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -27,9 +29,14 @@ const OUTPUT_MAX_LENGTH = 1024 * 8;
|
|
|
27
29
|
export async function setupMCPServer(serverName, serverConfig) {
|
|
28
30
|
const { options, ...params } = serverConfig;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// Ensure log directory exists and open stderr log file
|
|
33
|
+
const logDir = path.join(AGENT_PROJECT_METADATA_DIR, "logs");
|
|
34
|
+
await mkdir(logDir, { recursive: true });
|
|
35
|
+
const logPath = path.join(logDir, `mcp--${serverName}.stderr`);
|
|
36
|
+
|
|
37
|
+
const client = await createMCPClient({
|
|
38
|
+
...params,
|
|
39
|
+
stderr: logPath,
|
|
33
40
|
});
|
|
34
41
|
|
|
35
42
|
const tools = (await createMCPTools(serverName, client)).filter(
|
|
@@ -42,65 +49,17 @@ export async function setupMCPServer(serverName, serverConfig) {
|
|
|
42
49
|
|
|
43
50
|
return {
|
|
44
51
|
tools,
|
|
45
|
-
stderrLogPath,
|
|
52
|
+
stderrLogPath: logPath,
|
|
46
53
|
cleanup: async () => {
|
|
47
|
-
cleanup();
|
|
48
54
|
await client.close();
|
|
49
55
|
},
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
/**
|
|
54
|
-
* @typedef {Object} MCPClientOptions
|
|
55
|
-
* @property {string} serverName - The name of the MCP server.
|
|
56
|
-
* @property {import("@modelcontextprotocol/client").StdioServerParameters} params - The transport to use for the client.
|
|
57
|
-
*/
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* @param {MCPClientOptions} options - The options for the client.
|
|
61
|
-
* @returns {Promise<{client: Client; stderrLogPath: string; cleanup: () => void}>} - The MCP client, stderr log path, and cleanup function.
|
|
62
|
-
*/
|
|
63
|
-
async function startMCPServer(options) {
|
|
64
|
-
const mcpClient = await import("@modelcontextprotocol/client");
|
|
65
|
-
|
|
66
|
-
const client = new mcpClient.Client({
|
|
67
|
-
name: "undefined",
|
|
68
|
-
version: "undefined",
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const { env, ...restParams } = options.params;
|
|
72
|
-
const defaultEnv = {
|
|
73
|
-
PWD: process.env.PWD || "",
|
|
74
|
-
PATH: process.env.PATH || "",
|
|
75
|
-
HOME: process.env.HOME || "",
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
// Ensure log directory exists and open stderr log file
|
|
79
|
-
const logDir = path.join(AGENT_PROJECT_METADATA_DIR, "logs");
|
|
80
|
-
await mkdir(logDir, { recursive: true });
|
|
81
|
-
const logPath = path.join(logDir, `mcp--${options.serverName}.stderr`);
|
|
82
|
-
const stderrLogFile = await open(logPath, "a");
|
|
83
|
-
|
|
84
|
-
const transport = new mcpClient.StdioClientTransport({
|
|
85
|
-
...restParams,
|
|
86
|
-
env: env ? { ...defaultEnv, ...env } : undefined,
|
|
87
|
-
stderr: stderrLogFile.fd,
|
|
88
|
-
});
|
|
89
|
-
await client.connect(transport);
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
client,
|
|
93
|
-
stderrLogPath: logPath,
|
|
94
|
-
cleanup: () => {
|
|
95
|
-
stderrLogFile.close();
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
59
|
/**
|
|
101
60
|
* @param {string} serverName
|
|
102
|
-
* @param {
|
|
103
|
-
* @returns {Promise<Tool[]>}
|
|
61
|
+
* @param {MCPClient} client
|
|
62
|
+
* @returns {Promise<Tool[]>}
|
|
104
63
|
*/
|
|
105
64
|
async function createMCPTools(serverName, client) {
|
|
106
65
|
const { tools: mcpTools } = await client.listTools();
|
package/src/modelDefinition.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { AnthropicModelConfig } from "./providers/anthropic";
|
|
2
|
+
import { BedrockConverseModelConfig } from "./providers/bedrock";
|
|
2
3
|
import { GeminiModelConfig } from "./providers/gemini";
|
|
3
4
|
import { OpenAIModelConfig } from "./providers/openai";
|
|
4
5
|
import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible";
|
|
5
|
-
import { BedrockConverseModelConfig } from "./providers/bedrock";
|
|
6
6
|
|
|
7
7
|
export type ModelDefinition = {
|
|
8
8
|
name: string;
|
package/src/prompt.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* @typedef {object} PromptConfig
|
|
3
3
|
* @property {string} username
|
|
4
4
|
* @property {string} modelName
|
|
5
|
+
* @property {string} workingDir - The current working directory.
|
|
6
|
+
* @property {string} today - Today's date in YYYY-MM-DD format.
|
|
5
7
|
* @property {string} sessionId
|
|
6
8
|
* @property {string} tmuxSessionId
|
|
7
|
-
* @property {string} workingDir - The current working directory.
|
|
8
9
|
* @property {string} projectMetadataDir - The directory where memory files are stored.
|
|
9
10
|
* @property {Map<string, import('./context/loadAgentRoles.mjs').AgentRole>} agentRoles - Available agent roles.
|
|
10
11
|
* @property {{filePath: string, description: string}[]} skills
|
|
@@ -18,6 +19,7 @@ export function createPrompt({
|
|
|
18
19
|
username,
|
|
19
20
|
modelName,
|
|
20
21
|
sessionId,
|
|
22
|
+
today,
|
|
21
23
|
tmuxSessionId,
|
|
22
24
|
workingDir,
|
|
23
25
|
projectMetadataDir,
|
|
@@ -103,7 +105,7 @@ Discover and apply project-specific rules and reusable skills.
|
|
|
103
105
|
## AGENTS.md (falling back to CLAUDE.md if not found): Project-specific rules, conventions, and commands.
|
|
104
106
|
|
|
105
107
|
Find: fd ["^(AGENTS|CLAUDE)\\.md$", "./", "--hidden", "--max-depth", "5"]
|
|
106
|
-
Read from root to
|
|
108
|
+
Read from the project root to the directory you're working in: ./AGENTS.md → dir/AGENTS.md → dir/subdir/AGENTS.md
|
|
107
109
|
Apply rules when working in that directory
|
|
108
110
|
|
|
109
111
|
## SKILL.md: Reusable workflows with specialized knowledge
|
|
@@ -117,6 +119,7 @@ ${skillDescriptions}
|
|
|
117
119
|
- User name: ${username}
|
|
118
120
|
- Your model name: ${modelName}
|
|
119
121
|
- Current working directory: ${workingDir}
|
|
122
|
+
- Today's date: ${today}
|
|
120
123
|
- Session id: ${sessionId}
|
|
121
124
|
- Tmux session id: ${tmuxSessionId}
|
|
122
125
|
- Memory file path: ${projectMetadataDir}/memory/${sessionId}--<kebab-case-title>.md
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { styleText } from "node:util";
|
|
8
8
|
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
|
+
import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
|
|
9
10
|
import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
|
|
10
11
|
import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
|
|
11
12
|
|
|
@@ -108,39 +109,28 @@ export async function callAnthropicModel(
|
|
|
108
109
|
|
|
109
110
|
// bedrock + sso profile
|
|
110
111
|
const runFetchForBedrock = async () => {
|
|
111
|
-
const { Sha256 } = await import("@aws-crypto/sha256-js");
|
|
112
|
-
const { fromIni } = await import("@aws-sdk/credential-providers");
|
|
113
|
-
const { HttpRequest } = await import("@smithy/protocol-http");
|
|
114
|
-
const { SignatureV4 } = await import("@smithy/signature-v4");
|
|
115
|
-
|
|
116
112
|
const region =
|
|
117
113
|
url.match(/bedrock-runtime\.([\w-]+)\.amazonaws\.com/)?.[1] ?? "";
|
|
118
114
|
const urlParsed = new URL(url);
|
|
119
115
|
const { hostname, pathname } = urlParsed;
|
|
120
116
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
|
|
125
|
-
}),
|
|
126
|
-
region,
|
|
127
|
-
service: "bedrock",
|
|
128
|
-
sha256: Sha256,
|
|
129
|
-
});
|
|
117
|
+
const credentials = await loadAwsCredentials(
|
|
118
|
+
platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
|
|
119
|
+
);
|
|
130
120
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
121
|
+
const signed = signAwsRequest(
|
|
122
|
+
{
|
|
123
|
+
method: "POST",
|
|
124
|
+
hostname,
|
|
125
|
+
path: pathname,
|
|
126
|
+
headers: {
|
|
127
|
+
host: hostname,
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(request),
|
|
139
131
|
},
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const signed = await signer.sign(req);
|
|
132
|
+
{ region, service: "bedrock", credentials },
|
|
133
|
+
);
|
|
144
134
|
|
|
145
135
|
return fetch(url, {
|
|
146
136
|
method: signed.method,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { styleText } from "node:util";
|
|
8
8
|
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
|
+
import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
|
|
9
10
|
import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -21,11 +22,6 @@ export async function callBedrockConverseModel(
|
|
|
21
22
|
input,
|
|
22
23
|
retryCount = 0,
|
|
23
24
|
) {
|
|
24
|
-
const { Sha256 } = await import("@aws-crypto/sha256-js");
|
|
25
|
-
const { fromIni } = await import("@aws-sdk/credential-providers");
|
|
26
|
-
const { HttpRequest } = await import("@smithy/protocol-http");
|
|
27
|
-
const { SignatureV4 } = await import("@smithy/signature-v4");
|
|
28
|
-
|
|
29
25
|
return await noThrow(async () => {
|
|
30
26
|
const messages = convertGenericMessageToBedrockFormat(input.messages);
|
|
31
27
|
const cachedMessages = modelConfig.enablePromptCaching
|
|
@@ -75,29 +71,23 @@ export async function callBedrockConverseModel(
|
|
|
75
71
|
const payload = JSON.stringify(request);
|
|
76
72
|
|
|
77
73
|
// Sign request with AWS Signature V4
|
|
78
|
-
const
|
|
79
|
-
credentials: fromIni({ profile: platformConfig.awsProfile }),
|
|
80
|
-
region,
|
|
81
|
-
service: "bedrock",
|
|
82
|
-
sha256: Sha256,
|
|
83
|
-
});
|
|
84
|
-
|
|
74
|
+
const credentials = await loadAwsCredentials(platformConfig.awsProfile);
|
|
85
75
|
const urlParsed = new URL(url);
|
|
86
76
|
const { hostname, pathname } = urlParsed;
|
|
87
77
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
const signed = signAwsRequest(
|
|
79
|
+
{
|
|
80
|
+
method: "POST",
|
|
81
|
+
hostname,
|
|
82
|
+
path: pathname,
|
|
83
|
+
headers: {
|
|
84
|
+
host: hostname,
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
},
|
|
87
|
+
body: payload,
|
|
96
88
|
},
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const signed = await signer.sign(req);
|
|
89
|
+
{ region, service: "bedrock", credentials },
|
|
90
|
+
);
|
|
101
91
|
|
|
102
92
|
const response = await fetch(url, {
|
|
103
93
|
method: signed.method,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { styleText } from "node:util";
|
|
8
8
|
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
9
|
import { retryOnError } from "../utils/retryOnError.mjs";
|
|
10
|
+
import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
|
|
10
11
|
import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
|
|
11
12
|
import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
|
|
12
13
|
|
|
@@ -111,39 +112,28 @@ export async function callOpenAICompatibleModel(
|
|
|
111
112
|
|
|
112
113
|
// bedrock + sso profile
|
|
113
114
|
const runFetchForBedrock = async () => {
|
|
114
|
-
const { Sha256 } = await import("@aws-crypto/sha256-js");
|
|
115
|
-
const { fromIni } = await import("@aws-sdk/credential-providers");
|
|
116
|
-
const { HttpRequest } = await import("@smithy/protocol-http");
|
|
117
|
-
const { SignatureV4 } = await import("@smithy/signature-v4");
|
|
118
|
-
|
|
119
115
|
const region =
|
|
120
116
|
url.match(/bedrock-runtime\.([\w-]+)\.amazonaws\.com/)?.[1] ?? "";
|
|
121
117
|
const urlParsed = new URL(url);
|
|
122
118
|
const { hostname, pathname } = urlParsed;
|
|
123
119
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
|
|
128
|
-
}),
|
|
129
|
-
region,
|
|
130
|
-
service: "bedrock",
|
|
131
|
-
sha256: Sha256,
|
|
132
|
-
});
|
|
120
|
+
const credentials = await loadAwsCredentials(
|
|
121
|
+
platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
|
|
122
|
+
);
|
|
133
123
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
124
|
+
const signed = signAwsRequest(
|
|
125
|
+
{
|
|
126
|
+
method: "POST",
|
|
127
|
+
hostname,
|
|
128
|
+
path: pathname,
|
|
129
|
+
headers: {
|
|
130
|
+
host: hostname,
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify(request),
|
|
142
134
|
},
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const signed = await signer.sign(req);
|
|
135
|
+
{ region, service: "bedrock", credentials },
|
|
136
|
+
);
|
|
147
137
|
|
|
148
138
|
return fetch(url, {
|
|
149
139
|
method: signed.method,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, createHmac } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{ accessKeyId: string, secretAccessKey: string, sessionToken?: string }} AwsCredentials
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load AWS credentials for the given profile using the AWS CLI.
|
|
10
|
+
* @param {string} profile
|
|
11
|
+
* @returns {Promise<AwsCredentials>}
|
|
12
|
+
*/
|
|
13
|
+
export async function loadAwsCredentials(profile) {
|
|
14
|
+
/** @type {string} */
|
|
15
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
16
|
+
execFile(
|
|
17
|
+
"aws",
|
|
18
|
+
["configure", "export-credentials", "--profile", profile],
|
|
19
|
+
{
|
|
20
|
+
shell: false,
|
|
21
|
+
timeout: 30 * 1000,
|
|
22
|
+
},
|
|
23
|
+
(error, stdout, _stderr) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
reject(error);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
resolve(stdout.trim());
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
const parsed = JSON.parse(stdout);
|
|
33
|
+
for (const key of ["AccessKeyId", "SecretAccessKey"]) {
|
|
34
|
+
if (!parsed[key] || typeof parsed[key] !== "string") {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`AWS credentials output missing ${key}. Raw output: ${stdout.slice(0, 200)}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
accessKeyId: parsed.AccessKeyId,
|
|
42
|
+
secretAccessKey: parsed.SecretAccessKey,
|
|
43
|
+
...(parsed.SessionToken && { sessionToken: parsed.SessionToken }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sign an HTTP request with AWS Signature V4.
|
|
49
|
+
*
|
|
50
|
+
* Known limitation: if duplicate header names with different casing exist
|
|
51
|
+
* (e.g. `Content-Type` and `content-type`), only the first match is used.
|
|
52
|
+
* Per AWS SigV4 spec, values should be combined with commas. Callers should
|
|
53
|
+
* ensure header names are unique (case-insensitive) before signing.
|
|
54
|
+
*
|
|
55
|
+
* @param {{
|
|
56
|
+
* method: string,
|
|
57
|
+
* hostname: string,
|
|
58
|
+
* path: string,
|
|
59
|
+
* headers: Record<string, string>,
|
|
60
|
+
* body: string,
|
|
61
|
+
* }} request
|
|
62
|
+
* @param {{
|
|
63
|
+
* region: string,
|
|
64
|
+
* service: string,
|
|
65
|
+
* credentials: AwsCredentials,
|
|
66
|
+
* }} options
|
|
67
|
+
* @returns {{ method: string, headers: Record<string, string>, body: string }}
|
|
68
|
+
*/
|
|
69
|
+
export function signAwsRequest(request, options) {
|
|
70
|
+
const { method, hostname, path, headers, body } = request;
|
|
71
|
+
const { region, service, credentials } = options;
|
|
72
|
+
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const amzDate = now
|
|
75
|
+
.toISOString()
|
|
76
|
+
.replace(/[-:]/g, "")
|
|
77
|
+
.replace(/\.\d{3}/, "");
|
|
78
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
79
|
+
|
|
80
|
+
/** @type {Record<string, string>} */
|
|
81
|
+
const signedHeaders = { ...headers, host: hostname, "x-amz-date": amzDate };
|
|
82
|
+
if (credentials.sessionToken) {
|
|
83
|
+
signedHeaders["x-amz-security-token"] = credentials.sessionToken;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Canonical headers: sorted, lowercased, trimmed
|
|
87
|
+
const sortedKeys = Object.keys(signedHeaders)
|
|
88
|
+
.map((k) => k.toLowerCase())
|
|
89
|
+
.sort();
|
|
90
|
+
const canonicalHeaders = sortedKeys
|
|
91
|
+
.map((k) => {
|
|
92
|
+
const original = Object.keys(signedHeaders).find(
|
|
93
|
+
(h) => h.toLowerCase() === k,
|
|
94
|
+
);
|
|
95
|
+
return `${k}:${signedHeaders[/** @type {string} */ (original)].trim()}`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
const signedHeadersList = sortedKeys.join(";");
|
|
99
|
+
|
|
100
|
+
const payloadHash = sha256Hex(body || "");
|
|
101
|
+
|
|
102
|
+
// AWS SigV4 Canonical URI requires each path segment to be URI-encoded.
|
|
103
|
+
// URL.pathname returns a decoded string, so we need to re-encode it.
|
|
104
|
+
const canonicalUri = path
|
|
105
|
+
.split("/")
|
|
106
|
+
.map((segment) => encodeURIComponent(segment))
|
|
107
|
+
.join("/");
|
|
108
|
+
|
|
109
|
+
const canonicalRequest = [
|
|
110
|
+
method,
|
|
111
|
+
canonicalUri,
|
|
112
|
+
"", // query string (empty for POST)
|
|
113
|
+
`${canonicalHeaders}\n`,
|
|
114
|
+
signedHeadersList,
|
|
115
|
+
payloadHash,
|
|
116
|
+
].join("\n");
|
|
117
|
+
|
|
118
|
+
// String to sign
|
|
119
|
+
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
120
|
+
const stringToSign = [
|
|
121
|
+
"AWS4-HMAC-SHA256",
|
|
122
|
+
amzDate,
|
|
123
|
+
scope,
|
|
124
|
+
sha256Hex(canonicalRequest),
|
|
125
|
+
].join("\n");
|
|
126
|
+
|
|
127
|
+
// Signing key
|
|
128
|
+
const kDate = hmacSha256(`AWS4${credentials.secretAccessKey}`, dateStamp);
|
|
129
|
+
const kRegion = hmacSha256(kDate, region);
|
|
130
|
+
const kService = hmacSha256(kRegion, service);
|
|
131
|
+
const kSigning = hmacSha256(kService, "aws4_request");
|
|
132
|
+
|
|
133
|
+
const signature = createHmac("sha256", kSigning)
|
|
134
|
+
.update(stringToSign, "utf-8")
|
|
135
|
+
.digest("hex");
|
|
136
|
+
|
|
137
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeadersList}, Signature=${signature}`;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
method,
|
|
141
|
+
headers: { ...signedHeaders, Authorization: authorization },
|
|
142
|
+
body,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} data
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
function sha256Hex(data) {
|
|
151
|
+
return createHash("sha256").update(data, "utf-8").digest("hex");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string | Buffer} key
|
|
156
|
+
* @param {string} data
|
|
157
|
+
* @returns {Buffer}
|
|
158
|
+
*/
|
|
159
|
+
function hmacSha256(key, data) {
|
|
160
|
+
return createHmac("sha256", key).update(data, "utf-8").digest();
|
|
161
|
+
}
|
package/src/tools/patchFile.mjs
CHANGED
|
@@ -57,6 +57,32 @@ other new content
|
|
|
57
57
|
await noThrow(async () => {
|
|
58
58
|
const { filePath, diff } = input;
|
|
59
59
|
|
|
60
|
+
// Validate marker counts: each block needs exactly one of each marker.
|
|
61
|
+
// Since nonce is random, duplicate markers mean the user accidentally
|
|
62
|
+
// included a marker line in their search/replace content (copy-paste error).
|
|
63
|
+
const searchMarker = `<<< ${nonce} <<< SEARCH`;
|
|
64
|
+
const sepMarker = `=== ${nonce} ===`;
|
|
65
|
+
const replaceMarker = `>>> ${nonce} >>> REPLACE`;
|
|
66
|
+
/** @type {(s: string, sub: string) => number} */
|
|
67
|
+
const count = (s, sub) => s.split(sub).length - 1;
|
|
68
|
+
const nSearch = count(diff, searchMarker);
|
|
69
|
+
const nSep = count(diff, sepMarker);
|
|
70
|
+
const nReplace = count(diff, replaceMarker);
|
|
71
|
+
|
|
72
|
+
if (nSearch !== nReplace) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Mismatched block markers: found ${nSearch} "${searchMarker}" but ${nReplace} "${replaceMarker}". ` +
|
|
75
|
+
"Did you accidentally include a marker in your search/replace content?",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (nSep !== nSearch) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Each diff block needs exactly one "${sepMarker}" separator, ` +
|
|
81
|
+
`but found ${nSep} separators for ${nSearch} block(s). ` +
|
|
82
|
+
"Did you accidentally include the separator marker in your search/replace content?",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
60
86
|
const content = await fs.readFile(filePath, "utf8");
|
|
61
87
|
const matches = Array.from(
|
|
62
88
|
diff.matchAll(
|