@akira-tl/forgerelay 0.1.1 → 0.2.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/CHANGELOG.md +23 -0
- package/README.md +51 -6
- package/dist/artifact-tools.js +28 -14
- package/dist/cli.js +46 -3
- package/dist/config.js +2 -0
- package/dist/db/migrations.js +8 -0
- package/dist/db/schema.js +1 -0
- package/dist/hook-cli.js +100 -0
- package/dist/hooks.js +542 -0
- package/dist/local-agent-store.js +14 -1
- package/dist/mcp/server-instructions.js +2 -1
- package/dist/process-platform.js +1 -0
- package/dist/server.js +542 -415
- package/dist/user-config.js +33 -1
- package/dist/workspaces.js +45 -1
- package/docs/configuration.md +125 -0
- package/docs/debugging.md +126 -0
- package/docs/roadmap.md +16 -21
- package/docs/security.md +14 -0
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +615 -0
- package/scripts/debug/config.json +40 -0
- package/scripts/debug/hook-recorder.mjs +35 -0
- package/scripts/debug/runtime.mjs +47 -0
- package/scripts/debug/serve.mjs +37 -0
- package/scripts/dev-server.mjs +1 -1
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
15
|
+
import {
|
|
16
|
+
createDebugEnvironment,
|
|
17
|
+
debugBaseUrl,
|
|
18
|
+
debugMcpUrl,
|
|
19
|
+
debugRoot,
|
|
20
|
+
repoRoot,
|
|
21
|
+
} from "./runtime.mjs";
|
|
22
|
+
|
|
23
|
+
const packageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
|
|
24
|
+
const acceptanceRoot = resolve(debugRoot, "acceptance");
|
|
25
|
+
const stateDir = resolve(acceptanceRoot, "state");
|
|
26
|
+
const worktreeRoot = resolve(acceptanceRoot, "worktrees");
|
|
27
|
+
const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
|
|
28
|
+
const checkoutWorkspace = resolve(acceptanceRoot, "workspace");
|
|
29
|
+
const gitProject = resolve(acceptanceRoot, "git-project");
|
|
30
|
+
const releaseProject = resolve(acceptanceRoot, "release-project");
|
|
31
|
+
const releaseRemote = resolve(acceptanceRoot, "release-remote.git");
|
|
32
|
+
const ownerToken = randomBytes(32).toString("base64url");
|
|
33
|
+
|
|
34
|
+
assertCurlAvailable();
|
|
35
|
+
await assertDebugPortFree();
|
|
36
|
+
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
37
|
+
mkdirSync(acceptanceRoot, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const { env } = createDebugEnvironment({ ownerToken, stateDir, worktreeRoot, hookLog });
|
|
40
|
+
const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
|
|
41
|
+
cwd: repoRoot,
|
|
42
|
+
env,
|
|
43
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const health = await waitForHealth(server);
|
|
48
|
+
assert.deepEqual(health, { ok: true, name: "forgerelay" });
|
|
49
|
+
pass("health", JSON.stringify(health));
|
|
50
|
+
|
|
51
|
+
const protectedResource = jsonRequest(`${debugBaseUrl}/.well-known/oauth-protected-resource/mcp`);
|
|
52
|
+
assert.equal(protectedResource.status, 200);
|
|
53
|
+
assert.equal(protectedResource.json.resource, debugMcpUrl);
|
|
54
|
+
assert.equal(protectedResource.json.resource_name, "ForgeRelay");
|
|
55
|
+
pass("OAuth protected-resource discovery", protectedResource.json.resource);
|
|
56
|
+
|
|
57
|
+
const authorizationServer = jsonRequest(`${debugBaseUrl}/.well-known/oauth-authorization-server`);
|
|
58
|
+
assert.equal(authorizationServer.status, 200);
|
|
59
|
+
assert.equal(authorizationServer.json.authorization_endpoint, `${debugBaseUrl}/authorize`);
|
|
60
|
+
assert.equal(authorizationServer.json.token_endpoint, `${debugBaseUrl}/token`);
|
|
61
|
+
assert.equal(authorizationServer.json.registration_endpoint, `${debugBaseUrl}/register`);
|
|
62
|
+
pass("OAuth authorization-server discovery", authorizationServer.json.token_endpoint);
|
|
63
|
+
|
|
64
|
+
const unauthorized = curlRequest({
|
|
65
|
+
method: "POST",
|
|
66
|
+
url: debugMcpUrl,
|
|
67
|
+
headers: mcpHeaders(),
|
|
68
|
+
body: JSON.stringify(initializeRequest(1)),
|
|
69
|
+
});
|
|
70
|
+
assert.equal(unauthorized.status, 401);
|
|
71
|
+
assert.equal(JSON.parse(unauthorized.body).error, "invalid_token");
|
|
72
|
+
pass("unauthorized MCP is rejected", "HTTP 401 invalid_token");
|
|
73
|
+
|
|
74
|
+
const oauth = authorizeDebugClient(authorizationServer.json);
|
|
75
|
+
pass("OAuth owner-password flow", "registered client and issued access token");
|
|
76
|
+
|
|
77
|
+
const initialized = mcpRequest(oauth.accessToken, undefined, initializeRequest(1));
|
|
78
|
+
assert.equal(initialized.response.status, 200);
|
|
79
|
+
const sessionId = initialized.response.headers.get("mcp-session-id");
|
|
80
|
+
assert.ok(sessionId);
|
|
81
|
+
assert.equal(initialized.message.result.serverInfo.name, "forgerelay");
|
|
82
|
+
assert.equal(initialized.message.result.serverInfo.title, "ForgeRelay");
|
|
83
|
+
assert.equal(initialized.message.result.serverInfo.version, packageJson.version);
|
|
84
|
+
pass(
|
|
85
|
+
"MCP initialize",
|
|
86
|
+
JSON.stringify(initialized.message.result.serverInfo),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const initializedNotification = curlRequest({
|
|
90
|
+
method: "POST",
|
|
91
|
+
url: debugMcpUrl,
|
|
92
|
+
headers: mcpHeaders(oauth.accessToken, sessionId),
|
|
93
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
94
|
+
});
|
|
95
|
+
assert.equal(initializedNotification.status, 202);
|
|
96
|
+
|
|
97
|
+
const tools = mcpRequest(oauth.accessToken, sessionId, {
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
id: 2,
|
|
100
|
+
method: "tools/list",
|
|
101
|
+
params: {},
|
|
102
|
+
}).message.result.tools;
|
|
103
|
+
const toolNames = tools.map((tool) => tool.name);
|
|
104
|
+
for (const expected of ["open_workspace", "close_worktree", "read", "write", "edit", "grep", "glob", "ls", "bash"]) {
|
|
105
|
+
assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
|
|
106
|
+
}
|
|
107
|
+
pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
|
|
108
|
+
|
|
109
|
+
const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
|
|
110
|
+
path: checkoutWorkspace,
|
|
111
|
+
});
|
|
112
|
+
const workspaceId = opened.structuredContent.workspaceId;
|
|
113
|
+
assert.match(workspaceId, /^ws_/);
|
|
114
|
+
assert.equal(opened.structuredContent.root, checkoutWorkspace);
|
|
115
|
+
assert.equal(opened.structuredContent.mode, "checkout");
|
|
116
|
+
pass("open_workspace", `${workspaceId} -> ${opened.structuredContent.root}`);
|
|
117
|
+
|
|
118
|
+
const written = callTool(oauth.accessToken, sessionId, 4, "write", {
|
|
119
|
+
workspaceId,
|
|
120
|
+
path: "acceptance.txt",
|
|
121
|
+
content: "forgerelay 7677 acceptance\n",
|
|
122
|
+
});
|
|
123
|
+
assert.equal(written.isError, undefined);
|
|
124
|
+
|
|
125
|
+
const read = callTool(oauth.accessToken, sessionId, 5, "read", {
|
|
126
|
+
workspaceId,
|
|
127
|
+
path: "acceptance.txt",
|
|
128
|
+
});
|
|
129
|
+
assert.match(read.structuredContent.result, /forgerelay 7677 acceptance/);
|
|
130
|
+
pass("write + read", JSON.stringify(read.structuredContent));
|
|
131
|
+
|
|
132
|
+
const shell = callTool(oauth.accessToken, sessionId, 6, "bash", {
|
|
133
|
+
workspaceId,
|
|
134
|
+
command: "printf debug-bash-ok",
|
|
135
|
+
});
|
|
136
|
+
assert.match(shell.structuredContent.result, /debug-bash-ok/);
|
|
137
|
+
pass("bash", "debug-bash-ok");
|
|
138
|
+
|
|
139
|
+
const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
|
|
140
|
+
workspaceId,
|
|
141
|
+
path: "acceptance.txt",
|
|
142
|
+
edits: [{ oldText: "text that is not present", newText: "unused" }],
|
|
143
|
+
});
|
|
144
|
+
assert.equal(failedEdit.isError, true);
|
|
145
|
+
pass("failed tool path", "edit returned isError=true and triggered AfterToolFailure");
|
|
146
|
+
|
|
147
|
+
setupGitProject(gitProject);
|
|
148
|
+
const worktreeOpened = callTool(oauth.accessToken, sessionId, 8, "open_workspace", {
|
|
149
|
+
path: gitProject,
|
|
150
|
+
mode: "worktree",
|
|
151
|
+
});
|
|
152
|
+
const worktreeWorkspaceId = worktreeOpened.structuredContent.workspaceId;
|
|
153
|
+
const managedWorktreePath = worktreeOpened.structuredContent.worktree.path;
|
|
154
|
+
assert.equal(worktreeOpened.structuredContent.mode, "worktree");
|
|
155
|
+
assert.ok(existsSync(managedWorktreePath));
|
|
156
|
+
|
|
157
|
+
callTool(oauth.accessToken, sessionId, 9, "write", {
|
|
158
|
+
workspaceId: worktreeWorkspaceId,
|
|
159
|
+
path: "feature.txt",
|
|
160
|
+
content: "debug worktree acceptance\n",
|
|
161
|
+
});
|
|
162
|
+
const closed = callTool(oauth.accessToken, sessionId, 10, "close_worktree", {
|
|
163
|
+
workspaceId: worktreeWorkspaceId,
|
|
164
|
+
commitMessage: "test(debug): verify 7677 worktree lifecycle",
|
|
165
|
+
});
|
|
166
|
+
assert.equal(closed.structuredContent.committed, true);
|
|
167
|
+
assert.equal(existsSync(managedWorktreePath), false);
|
|
168
|
+
assert.equal(
|
|
169
|
+
readFileSync(join(gitProject, "feature.txt"), "utf8").replace(/\r\n/g, "\n"),
|
|
170
|
+
"debug worktree acceptance\n",
|
|
171
|
+
);
|
|
172
|
+
pass(
|
|
173
|
+
"managed worktree close",
|
|
174
|
+
`${closed.structuredContent.branch} -> ${closed.structuredContent.targetBranch}`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
exerciseReleaseTagHooks(oauth.accessToken, sessionId);
|
|
178
|
+
exerciseSubagentHooks(env, stateDir, workspaceId, checkoutWorkspace);
|
|
179
|
+
|
|
180
|
+
const hookEntries = readHookEntries(hookLog);
|
|
181
|
+
const hookEvents = hookEntries.map((entry) => entry.event);
|
|
182
|
+
for (const expected of [
|
|
183
|
+
"WorkspaceOpen",
|
|
184
|
+
"BeforeTool",
|
|
185
|
+
"AfterTool",
|
|
186
|
+
"AfterToolFailure",
|
|
187
|
+
"AfterFileChange",
|
|
188
|
+
"BeforeWorktreeClose",
|
|
189
|
+
"AfterWorktreeClose",
|
|
190
|
+
"SubagentStart",
|
|
191
|
+
"SubagentStop",
|
|
192
|
+
]) {
|
|
193
|
+
assert.ok(hookEvents.includes(expected), `debug hook log did not contain ${expected}`);
|
|
194
|
+
}
|
|
195
|
+
assert.ok(
|
|
196
|
+
hookEvents.indexOf("BeforeWorktreeClose") < hookEvents.indexOf("AfterWorktreeClose"),
|
|
197
|
+
"worktree close hooks were recorded out of order",
|
|
198
|
+
);
|
|
199
|
+
pass("Hooks v1 dogfood", Array.from(new Set(hookEvents)).join(", "));
|
|
200
|
+
|
|
201
|
+
const deleteSession = curlRequest({
|
|
202
|
+
method: "DELETE",
|
|
203
|
+
url: debugMcpUrl,
|
|
204
|
+
headers: mcpHeaders(oauth.accessToken, sessionId),
|
|
205
|
+
});
|
|
206
|
+
assert.ok([200, 202, 204].includes(deleteSession.status));
|
|
207
|
+
|
|
208
|
+
console.log("\nForgeRelay 7677 acceptance passed.");
|
|
209
|
+
console.log(`Artifacts: ${acceptanceRoot}`);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error("\nForgeRelay 7677 acceptance failed.");
|
|
212
|
+
throw error;
|
|
213
|
+
} finally {
|
|
214
|
+
await stopServer(server);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function authorizeDebugClient(metadata) {
|
|
218
|
+
const redirectUri = `${debugBaseUrl}/debug/callback`;
|
|
219
|
+
const registration = jsonRequest(metadata.registration_endpoint, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
client_name: "ForgeRelay 7677 acceptance",
|
|
223
|
+
redirect_uris: [redirectUri],
|
|
224
|
+
token_endpoint_auth_method: "none",
|
|
225
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
226
|
+
response_types: ["code"],
|
|
227
|
+
}),
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
});
|
|
230
|
+
assert.equal(registration.status, 201);
|
|
231
|
+
|
|
232
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
233
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
234
|
+
const authorizationBody = new URLSearchParams({
|
|
235
|
+
response_type: "code",
|
|
236
|
+
client_id: registration.json.client_id,
|
|
237
|
+
redirect_uri: redirectUri,
|
|
238
|
+
code_challenge: challenge,
|
|
239
|
+
code_challenge_method: "S256",
|
|
240
|
+
scope: "devspace",
|
|
241
|
+
resource: debugMcpUrl,
|
|
242
|
+
state: "forgerelay-debug-acceptance",
|
|
243
|
+
owner_token: ownerToken,
|
|
244
|
+
}).toString();
|
|
245
|
+
const authorization = curlRequest({
|
|
246
|
+
method: "POST",
|
|
247
|
+
url: metadata.authorization_endpoint,
|
|
248
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
249
|
+
body: authorizationBody,
|
|
250
|
+
});
|
|
251
|
+
assert.equal(authorization.status, 302);
|
|
252
|
+
const redirect = new URL(authorization.headers.get("location"));
|
|
253
|
+
assert.equal(redirect.origin + redirect.pathname, redirectUri);
|
|
254
|
+
assert.equal(redirect.searchParams.get("state"), "forgerelay-debug-acceptance");
|
|
255
|
+
const code = redirect.searchParams.get("code");
|
|
256
|
+
assert.ok(code);
|
|
257
|
+
|
|
258
|
+
const token = jsonRequest(metadata.token_endpoint, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
261
|
+
body: new URLSearchParams({
|
|
262
|
+
grant_type: "authorization_code",
|
|
263
|
+
client_id: registration.json.client_id,
|
|
264
|
+
code,
|
|
265
|
+
redirect_uri: redirectUri,
|
|
266
|
+
code_verifier: verifier,
|
|
267
|
+
resource: debugMcpUrl,
|
|
268
|
+
}).toString(),
|
|
269
|
+
});
|
|
270
|
+
assert.equal(token.status, 200);
|
|
271
|
+
assert.equal(token.json.token_type, "bearer");
|
|
272
|
+
assert.equal(token.json.scope, "devspace");
|
|
273
|
+
assert.ok(token.json.access_token);
|
|
274
|
+
return { accessToken: token.json.access_token };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function initializeRequest(id) {
|
|
278
|
+
return {
|
|
279
|
+
jsonrpc: "2.0",
|
|
280
|
+
id,
|
|
281
|
+
method: "initialize",
|
|
282
|
+
params: {
|
|
283
|
+
protocolVersion: "2025-06-18",
|
|
284
|
+
capabilities: {},
|
|
285
|
+
clientInfo: { name: "forgerelay-debug-acceptance", version: "1.0.0" },
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mcpRequest(accessToken, sessionId, request) {
|
|
291
|
+
const response = curlRequest({
|
|
292
|
+
method: "POST",
|
|
293
|
+
url: debugMcpUrl,
|
|
294
|
+
headers: mcpHeaders(accessToken, sessionId),
|
|
295
|
+
body: JSON.stringify(request),
|
|
296
|
+
});
|
|
297
|
+
assert.equal(response.status, 200, response.body);
|
|
298
|
+
return { response, message: parseMcpMessage(response.body, request.id) };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function callTool(accessToken, sessionId, id, name, args) {
|
|
302
|
+
const message = mcpRequest(accessToken, sessionId, {
|
|
303
|
+
jsonrpc: "2.0",
|
|
304
|
+
id,
|
|
305
|
+
method: "tools/call",
|
|
306
|
+
params: { name, arguments: args },
|
|
307
|
+
}).message;
|
|
308
|
+
assert.equal(message.id, id);
|
|
309
|
+
assert.ok(message.result, `tool ${name} did not return a result`);
|
|
310
|
+
return message.result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function mcpHeaders(accessToken, sessionId) {
|
|
314
|
+
return {
|
|
315
|
+
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
|
316
|
+
...(sessionId ? { "mcp-session-id": sessionId } : {}),
|
|
317
|
+
"content-type": "application/json",
|
|
318
|
+
accept: "application/json, text/event-stream",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseMcpMessage(body, expectedId) {
|
|
323
|
+
const dataLines = body
|
|
324
|
+
.split(/\r?\n/)
|
|
325
|
+
.filter((line) => line.startsWith("data:"))
|
|
326
|
+
.map((line) => line.slice(5).trim())
|
|
327
|
+
.filter(Boolean);
|
|
328
|
+
const messages = dataLines.length > 0
|
|
329
|
+
? dataLines.map((line) => JSON.parse(line))
|
|
330
|
+
: [JSON.parse(body)];
|
|
331
|
+
return messages.find((message) => message.id === expectedId) ?? messages[0];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function jsonRequest(url, options = {}) {
|
|
335
|
+
const response = curlRequest({
|
|
336
|
+
method: options.method ?? "GET",
|
|
337
|
+
url,
|
|
338
|
+
headers: options.headers,
|
|
339
|
+
body: options.body,
|
|
340
|
+
});
|
|
341
|
+
return { ...response, json: JSON.parse(response.body) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function curlRequest({ method = "GET", url, headers = {}, body }) {
|
|
345
|
+
const marker = `__FORGERELAY_DEBUG_STATUS_${randomUUID()}__`;
|
|
346
|
+
const args = [
|
|
347
|
+
"--silent",
|
|
348
|
+
"--show-error",
|
|
349
|
+
"--max-time",
|
|
350
|
+
"15",
|
|
351
|
+
"--request",
|
|
352
|
+
method,
|
|
353
|
+
"--dump-header",
|
|
354
|
+
"-",
|
|
355
|
+
"--output",
|
|
356
|
+
"-",
|
|
357
|
+
"--write-out",
|
|
358
|
+
`\n${marker}%{http_code}`,
|
|
359
|
+
];
|
|
360
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
361
|
+
args.push("--header", `${name}: ${value}`);
|
|
362
|
+
}
|
|
363
|
+
if (body !== undefined) {
|
|
364
|
+
args.push("--data-binary", "@-");
|
|
365
|
+
}
|
|
366
|
+
args.push(url);
|
|
367
|
+
|
|
368
|
+
const result = spawnSync("curl", args, {
|
|
369
|
+
cwd: repoRoot,
|
|
370
|
+
input: body,
|
|
371
|
+
encoding: "utf8",
|
|
372
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
373
|
+
});
|
|
374
|
+
if (result.status !== 0) {
|
|
375
|
+
throw new Error(`curl ${method} ${url} failed: ${result.stderr.trim() || `exit ${result.status}`}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const statusMarker = `\n${marker}`;
|
|
379
|
+
const markerIndex = result.stdout.lastIndexOf(statusMarker);
|
|
380
|
+
assert.notEqual(markerIndex, -1, `curl response did not contain status marker for ${url}`);
|
|
381
|
+
const rawResponse = result.stdout.slice(0, markerIndex);
|
|
382
|
+
const status = Number(result.stdout.slice(markerIndex + statusMarker.length).trim());
|
|
383
|
+
const separator = rawResponse.indexOf("\r\n\r\n") >= 0 ? "\r\n\r\n" : "\n\n";
|
|
384
|
+
const headerEnd = rawResponse.indexOf(separator);
|
|
385
|
+
assert.notEqual(headerEnd, -1, `curl response did not contain headers for ${url}`);
|
|
386
|
+
const headerBlock = rawResponse.slice(0, headerEnd);
|
|
387
|
+
const responseBody = rawResponse.slice(headerEnd + separator.length);
|
|
388
|
+
const responseHeaders = new Map();
|
|
389
|
+
for (const line of headerBlock.split(/\r?\n/).slice(1)) {
|
|
390
|
+
const colon = line.indexOf(":");
|
|
391
|
+
if (colon < 0) continue;
|
|
392
|
+
responseHeaders.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
|
|
393
|
+
}
|
|
394
|
+
return { status, headers: responseHeaders, body: responseBody };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function waitForHealth(child) {
|
|
398
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
399
|
+
if (child.exitCode !== null) {
|
|
400
|
+
throw new Error(`debug server exited before health check: ${child.exitCode}`);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const response = jsonRequest(`${debugBaseUrl}/healthz`);
|
|
404
|
+
if (response.status === 200) return response.json;
|
|
405
|
+
} catch {
|
|
406
|
+
// Server is still starting.
|
|
407
|
+
}
|
|
408
|
+
await delay(100);
|
|
409
|
+
}
|
|
410
|
+
throw new Error("debug server did not become healthy on 127.0.0.1:7677");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function stopServer(child) {
|
|
414
|
+
if (child.exitCode !== null) return;
|
|
415
|
+
child.kill("SIGTERM");
|
|
416
|
+
const exited = once(child, "exit");
|
|
417
|
+
await Promise.race([
|
|
418
|
+
exited,
|
|
419
|
+
delay(3000).then(() => {
|
|
420
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
421
|
+
}),
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function setupGitProject(root) {
|
|
426
|
+
mkdirSync(root, { recursive: true });
|
|
427
|
+
writeFileSync(join(root, "README.md"), "debug git project\n", "utf8");
|
|
428
|
+
runGit(root, ["init"]);
|
|
429
|
+
runGit(root, ["config", "user.email", "forgerelay-debug@example.com"]);
|
|
430
|
+
runGit(root, ["config", "user.name", "ForgeRelay Debug"]);
|
|
431
|
+
runGit(root, ["add", "."]);
|
|
432
|
+
runGit(root, ["commit", "-m", "Initial debug commit"]);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function runGit(cwd, args) {
|
|
436
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
437
|
+
if (result.status !== 0) {
|
|
438
|
+
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function gitOutput(cwd, args, options = {}) {
|
|
443
|
+
const gitArgs = options.gitDir ? ["--git-dir", cwd, ...args] : args;
|
|
444
|
+
const result = spawnSync("git", gitArgs, {
|
|
445
|
+
...(options.gitDir ? {} : { cwd }),
|
|
446
|
+
encoding: "utf8",
|
|
447
|
+
});
|
|
448
|
+
if (result.status !== 0) {
|
|
449
|
+
throw new Error(`git ${gitArgs.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
450
|
+
}
|
|
451
|
+
return result.stdout.trim();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
455
|
+
setupGitProject(releaseProject);
|
|
456
|
+
runGit(acceptanceRoot, ["init", "--bare", releaseRemote]);
|
|
457
|
+
runGit(releaseProject, ["remote", "add", "origin", releaseRemote]);
|
|
458
|
+
mkdirSync(join(releaseProject, ".forgerelay", "hooks"), { recursive: true });
|
|
459
|
+
writeFileSync(
|
|
460
|
+
join(releaseProject, ".forgerelay", "release-check.mjs"),
|
|
461
|
+
[
|
|
462
|
+
'import { writeFileSync } from "node:fs";',
|
|
463
|
+
'const payload = process.env.FORGERELAY_HOOK_PAYLOAD ?? "{}";',
|
|
464
|
+
'writeFileSync("release-ci-ran.txt", payload);',
|
|
465
|
+
'if (JSON.parse(payload).command === "git push origin v0.2.1") process.exit(17);',
|
|
466
|
+
"",
|
|
467
|
+
].join("\n"),
|
|
468
|
+
);
|
|
469
|
+
writeFileSync(
|
|
470
|
+
join(releaseProject, ".forgerelay", "hooks", "release-tag-local-ci.json"),
|
|
471
|
+
JSON.stringify({
|
|
472
|
+
event: "BeforeTool",
|
|
473
|
+
matcher: { tool: "bash", commandRegex: "^git push origin v0\\.2\\.[01]$" },
|
|
474
|
+
command: "node .forgerelay/release-check.mjs",
|
|
475
|
+
timeoutSeconds: 30,
|
|
476
|
+
report: true,
|
|
477
|
+
}, null, 2) + "\n",
|
|
478
|
+
);
|
|
479
|
+
runGit(releaseProject, ["add", ".forgerelay"]);
|
|
480
|
+
runGit(releaseProject, ["commit", "-m", "Add release hook fixture"]);
|
|
481
|
+
runGit(releaseProject, ["tag", "v0.2.0"]);
|
|
482
|
+
runGit(releaseProject, ["tag", "v0.2.1"]);
|
|
483
|
+
|
|
484
|
+
const opened = callTool(accessToken, sessionId, 11, "open_workspace", {
|
|
485
|
+
path: releaseProject,
|
|
486
|
+
});
|
|
487
|
+
const releaseWorkspaceId = opened.structuredContent.workspaceId;
|
|
488
|
+
|
|
489
|
+
const pushed = callTool(accessToken, sessionId, 12, "bash", {
|
|
490
|
+
workspaceId: releaseWorkspaceId,
|
|
491
|
+
command: "git push origin v0.2.0",
|
|
492
|
+
});
|
|
493
|
+
assert.equal(pushed.isError, undefined);
|
|
494
|
+
assert.match(toolText(pushed), /release-tag-local-ci \(BeforeTool, project\) passed/);
|
|
495
|
+
assert.ok(existsSync(join(releaseProject, "release-ci-ran.txt")));
|
|
496
|
+
assert.equal(
|
|
497
|
+
gitOutput(releaseRemote, ["rev-parse", "refs/tags/v0.2.0"], { gitDir: true }),
|
|
498
|
+
gitOutput(releaseProject, ["rev-parse", "v0.2.0"]),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const blocked = callTool(accessToken, sessionId, 13, "bash", {
|
|
502
|
+
workspaceId: releaseWorkspaceId,
|
|
503
|
+
command: "git push origin v0.2.1",
|
|
504
|
+
});
|
|
505
|
+
assert.equal(blocked.isError, true);
|
|
506
|
+
assert.match(toolText(blocked), /release-tag-local-ci.*failed/);
|
|
507
|
+
const missingTag = spawnSync("git", ["--git-dir", releaseRemote, "show-ref", "--verify", "--quiet", "refs/tags/v0.2.1"]);
|
|
508
|
+
assert.notEqual(missingTag.status, 0, "blocked release tag unexpectedly reached the remote");
|
|
509
|
+
pass("release tag hook gate", "local Hook passed before v0.2.0 push and blocked v0.2.1 before remote mutation");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspaceRoot) {
|
|
513
|
+
const seedScript = [
|
|
514
|
+
'import { LocalAgentStore } from "./src/local-agent-store.js";',
|
|
515
|
+
'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
|
|
516
|
+
'const record = store.create({',
|
|
517
|
+
` workspaceId: ${JSON.stringify(workspaceId)},`,
|
|
518
|
+
` workspaceRoot: ${JSON.stringify(workspaceRoot)},`,
|
|
519
|
+
' profileName: "debug-missing-profile",',
|
|
520
|
+
' provider: "codex",',
|
|
521
|
+
'});',
|
|
522
|
+
'store.close();',
|
|
523
|
+
'process.stdout.write(record.id);',
|
|
524
|
+
].join("\n");
|
|
525
|
+
const seeded = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", seedScript], {
|
|
526
|
+
cwd: repoRoot,
|
|
527
|
+
env: { ...runtimeEnv, FORGERELAY_STATE_DIR: debugStateDir },
|
|
528
|
+
encoding: "utf8",
|
|
529
|
+
});
|
|
530
|
+
if (seeded.status !== 0) {
|
|
531
|
+
throw new Error(`unable to seed debug subagent: ${seeded.stderr.trim()}`);
|
|
532
|
+
}
|
|
533
|
+
const agentId = seeded.stdout.trim();
|
|
534
|
+
assert.match(agentId, /^agt_/);
|
|
535
|
+
|
|
536
|
+
const promptFile = join(acceptanceRoot, "subagent-prompt.txt");
|
|
537
|
+
writeFileSync(promptFile, "debug acceptance prompt that must not reach a provider\n", "utf8");
|
|
538
|
+
const worker = spawnSync(
|
|
539
|
+
process.execPath,
|
|
540
|
+
["--import", "tsx", "src/cli.ts", "agents", "__worker", agentId, "--prompt-file", promptFile],
|
|
541
|
+
{ cwd: repoRoot, env: runtimeEnv, encoding: "utf8", maxBuffer: 2 * 1024 * 1024 },
|
|
542
|
+
);
|
|
543
|
+
if (worker.status !== 0) {
|
|
544
|
+
throw new Error(`debug subagent worker failed to execute: ${worker.stderr.trim()}`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const inspectScript = [
|
|
548
|
+
'import { LocalAgentStore } from "./src/local-agent-store.js";',
|
|
549
|
+
'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
|
|
550
|
+
`const record = store.get(${JSON.stringify(agentId)});`,
|
|
551
|
+
'store.close();',
|
|
552
|
+
'process.stdout.write(JSON.stringify({ status: record?.status, error: record?.error }));',
|
|
553
|
+
].join("\n");
|
|
554
|
+
const inspected = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", inspectScript], {
|
|
555
|
+
cwd: repoRoot,
|
|
556
|
+
env: runtimeEnv,
|
|
557
|
+
encoding: "utf8",
|
|
558
|
+
});
|
|
559
|
+
if (inspected.status !== 0) {
|
|
560
|
+
throw new Error(`unable to inspect debug subagent: ${inspected.stderr.trim()}`);
|
|
561
|
+
}
|
|
562
|
+
const record = JSON.parse(inspected.stdout);
|
|
563
|
+
assert.equal(record.status, "error");
|
|
564
|
+
assert.match(record.error, /Subagent profile not found/);
|
|
565
|
+
pass("subagent hook path", `${agentId} stopped in deterministic error path without calling a provider`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function toolText(result) {
|
|
569
|
+
return (result.content ?? [])
|
|
570
|
+
.filter((entry) => entry?.type === "text" && typeof entry.text === "string")
|
|
571
|
+
.map((entry) => entry.text)
|
|
572
|
+
.join("\n");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function readHookEntries(path) {
|
|
576
|
+
return readFileSync(path, "utf8")
|
|
577
|
+
.trim()
|
|
578
|
+
.split(/\r?\n/)
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.map((line) => JSON.parse(line));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function assertDebugPortFree() {
|
|
584
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
585
|
+
const socket = connect({ host: "127.0.0.1", port: 7677 });
|
|
586
|
+
socket.setTimeout(500);
|
|
587
|
+
socket.once("connect", () => {
|
|
588
|
+
socket.destroy();
|
|
589
|
+
rejectPromise(new Error("debug port 7677 is already in use; stop the existing debug server first"));
|
|
590
|
+
});
|
|
591
|
+
socket.once("error", (error) => {
|
|
592
|
+
socket.destroy();
|
|
593
|
+
if (error.code === "ECONNREFUSED") {
|
|
594
|
+
resolvePromise();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
rejectPromise(error);
|
|
598
|
+
});
|
|
599
|
+
socket.once("timeout", () => {
|
|
600
|
+
socket.destroy();
|
|
601
|
+
resolvePromise();
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function assertCurlAvailable() {
|
|
607
|
+
const result = spawnSync("curl", ["--version"], { encoding: "utf8" });
|
|
608
|
+
if (result.status !== 0) {
|
|
609
|
+
throw new Error("npm run debug:accept requires curl on PATH.");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function pass(label, detail) {
|
|
614
|
+
console.log(`✓ ${label}: ${detail}`);
|
|
615
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"host": "127.0.0.1",
|
|
3
|
+
"port": 7677,
|
|
4
|
+
"allowedRoots": ["."],
|
|
5
|
+
"allowedHosts": ["localhost", "127.0.0.1", "::1"],
|
|
6
|
+
"publicBaseUrl": "http://127.0.0.1:7677",
|
|
7
|
+
"stateDir": ".forgerelay-debug/state",
|
|
8
|
+
"worktreeRoot": ".forgerelay-debug/worktrees",
|
|
9
|
+
"artifactsEnabled": false,
|
|
10
|
+
"subagents": false,
|
|
11
|
+
"hooks": {
|
|
12
|
+
"WorkspaceOpen": [
|
|
13
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
14
|
+
],
|
|
15
|
+
"BeforeTool": [
|
|
16
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
17
|
+
],
|
|
18
|
+
"AfterTool": [
|
|
19
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
20
|
+
],
|
|
21
|
+
"AfterToolFailure": [
|
|
22
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
23
|
+
],
|
|
24
|
+
"AfterFileChange": [
|
|
25
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
26
|
+
],
|
|
27
|
+
"BeforeWorktreeClose": [
|
|
28
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
29
|
+
],
|
|
30
|
+
"AfterWorktreeClose": [
|
|
31
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
32
|
+
],
|
|
33
|
+
"SubagentStart": [
|
|
34
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
35
|
+
],
|
|
36
|
+
"SubagentStop": [
|
|
37
|
+
{ "command": "node --input-type=module -e \"import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.env.FORGERELAY_DEBUG_HOOK_RECORDER).href)\"", "timeoutSeconds": 10, "report": false }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
const logPath = process.env.FORGERELAY_DEBUG_HOOK_LOG;
|
|
5
|
+
if (!logPath) {
|
|
6
|
+
throw new Error("FORGERELAY_DEBUG_HOOK_LOG is required by the debug hook recorder.");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let payload = {};
|
|
10
|
+
try {
|
|
11
|
+
payload = JSON.parse(process.env.FORGERELAY_HOOK_PAYLOAD ?? "{}");
|
|
12
|
+
} catch {
|
|
13
|
+
payload = { payloadParseError: true };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const entry = {
|
|
17
|
+
ts: new Date().toISOString(),
|
|
18
|
+
event: process.env.FORGERELAY_HOOK_EVENT,
|
|
19
|
+
workspaceId: process.env.FORGERELAY_WORKSPACE_ID,
|
|
20
|
+
workspaceRoot: process.env.FORGERELAY_WORKSPACE_ROOT,
|
|
21
|
+
workspaceMode: process.env.FORGERELAY_WORKSPACE_MODE,
|
|
22
|
+
sourceRoot: process.env.FORGERELAY_SOURCE_ROOT,
|
|
23
|
+
tool: process.env.FORGERELAY_TOOL_NAME,
|
|
24
|
+
path: payload.path,
|
|
25
|
+
paths: payload.paths,
|
|
26
|
+
status: payload.status,
|
|
27
|
+
branch: payload.branch,
|
|
28
|
+
targetBranch: payload.targetBranch,
|
|
29
|
+
agentId: payload.agentId,
|
|
30
|
+
profile: payload.profile,
|
|
31
|
+
provider: payload.provider,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
35
|
+
appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf8");
|