fix(#377): remediate code review and security findings
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/api Pipeline failed

- Fix sendThreadMessage room mismatch: use channelId from options instead of hardcoded controlRoomId
- Add .catch() to fire-and-forget handleRoomMessage to prevent silent error swallowing
- Wrap dispatchJob in try-catch for user-visible error reporting in handleFixCommand
- Add MATRIX_BOT_USER_ID validation in connect() to prevent infinite message loops
- Fix streamResponse error masking: wrap finally/catch side-effects in try-catch
- Replace unsafe type assertion with public getClient() in MatrixRoomService
- Add orphaned room warning in provisionRoom on DB failure
- Add provider identity to Herald error logs
- Add channelId to ThreadMessageOptions interface and all callers
- Add missing env var warnings in BridgeModule factory
- Fix JSON injection in setup-bot.sh: use jq for safe JSON construction

Fixes #377

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 03:00:53 -06:00
parent a1f0d1dd71
commit 8d19ac1f4b
13 changed files with 179 additions and 76 deletions

View File

@@ -46,6 +46,16 @@ const logger = new Logger("BridgeModule");
} }
if (process.env.MATRIX_ACCESS_TOKEN) { if (process.env.MATRIX_ACCESS_TOKEN) {
const missingVars = [
"MATRIX_HOMESERVER_URL",
"MATRIX_BOT_USER_ID",
"MATRIX_WORKSPACE_ID",
].filter((v) => !process.env[v]);
if (missingVars.length > 0) {
logger.warn(
`Matrix bridge enabled but missing: ${missingVars.join(", ")}. connect() will fail.`
);
}
providers.push(matrix); providers.push(matrix);
logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)"); logger.log("Matrix bridge enabled (MATRIX_ACCESS_TOKEN detected)");
} }

View File

@@ -187,6 +187,7 @@ describe("DiscordService", () => {
await service.connect(); await service.connect();
await service.sendThreadMessage({ await service.sendThreadMessage({
threadId: "thread-123", threadId: "thread-123",
channelId: "test-channel-id",
content: "Step completed", content: "Step completed",
}); });

View File

@@ -305,6 +305,7 @@ export class DiscordService implements IChatProvider {
// Send confirmation to thread // Send confirmation to thread
await this.sendThreadMessage({ await this.sendThreadMessage({
threadId, threadId,
channelId: message.channelId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`, content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
}); });
} }

View File

@@ -28,6 +28,7 @@ export interface ThreadCreateOptions {
export interface ThreadMessageOptions { export interface ThreadMessageOptions {
threadId: string; threadId: string;
channelId: string;
content: string; content: string;
} }

View File

@@ -486,9 +486,9 @@ describe("Matrix Bridge Integration Tests", () => {
}) })
); );
// Confirmation message sent as thread reply // Confirmation message sent as thread reply (uses channelId from message, not hardcoded controlRoomId)
const confirmationCall = sendCalls[1]; const confirmationCall = sendCalls[1];
expect(confirmationCall?.[0]).toBe("!control-room:example.com"); expect(confirmationCall?.[0]).toBe("!room:example.com");
expect(confirmationCall?.[1]).toEqual( expect(confirmationCall?.[1]).toEqual(
expect.objectContaining({ expect.objectContaining({
body: expect.stringContaining("Job created: job-integ-001"), body: expect.stringContaining("Job created: job-integ-001"),
@@ -519,7 +519,7 @@ describe("Matrix Bridge Integration Tests", () => {
setMatrixEnv(); setMatrixEnv();
// Create a connected mock MatrixService that tracks sendThreadMessage calls // Create a connected mock MatrixService that tracks sendThreadMessage calls
const threadMessages: Array<{ threadId: string; content: string }> = []; const threadMessages: Array<{ threadId: string; channelId: string; content: string }> = [];
const mockMatrixProvider: IChatProvider = { const mockMatrixProvider: IChatProvider = {
connect: vi.fn().mockResolvedValue(undefined), connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined),
@@ -527,7 +527,7 @@ describe("Matrix Bridge Integration Tests", () => {
sendMessage: vi.fn().mockResolvedValue(undefined), sendMessage: vi.fn().mockResolvedValue(undefined),
createThread: vi.fn().mockResolvedValue("$thread-id"), createThread: vi.fn().mockResolvedValue("$thread-id"),
sendThreadMessage: vi.fn().mockImplementation(async (options) => { sendThreadMessage: vi.fn().mockImplementation(async (options) => {
threadMessages.push(options as { threadId: string; content: string }); threadMessages.push(options as { threadId: string; channelId: string; content: string });
}), }),
parseCommand: vi.fn().mockReturnValue(null), parseCommand: vi.fn().mockReturnValue(null),
}; };
@@ -545,6 +545,7 @@ describe("Matrix Bridge Integration Tests", () => {
payload: { payload: {
metadata: { metadata: {
threadId: "$thread-herald-root", threadId: "$thread-herald-root",
channelId: "!herald-room:example.com",
issueNumber: 55, issueNumber: 55,
}, },
}, },
@@ -617,6 +618,7 @@ describe("Matrix Bridge Integration Tests", () => {
payload: { payload: {
metadata: { metadata: {
threadId: "$thread-skip", threadId: "$thread-skip",
channelId: "!skip-room:example.com",
issueNumber: 1, issueNumber: 1,
}, },
}, },
@@ -689,6 +691,7 @@ describe("Matrix Bridge Integration Tests", () => {
payload: { payload: {
metadata: { metadata: {
threadId: "$thread-err", threadId: "$thread-err",
channelId: "!err-room:example.com",
issueNumber: 77, issueNumber: 77,
}, },
}, },

View File

@@ -24,12 +24,13 @@ describe("MatrixRoomService", () => {
const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com"); const mockCreateRoom = vi.fn().mockResolvedValue("!new-room:example.com");
const mockMatrixClient = {
createRoom: mockCreateRoom,
};
const mockMatrixService = { const mockMatrixService = {
isConnected: vi.fn().mockReturnValue(true), isConnected: vi.fn().mockReturnValue(true),
// Private field accessed by MatrixRoomService.getMatrixClient() getClient: vi.fn().mockReturnValue(mockMatrixClient),
client: {
createRoom: mockCreateRoom,
},
}; };
const mockPrismaService = { const mockPrismaService = {

View File

@@ -64,10 +64,17 @@ export class MatrixRoomService {
const roomId = await client.createRoom(roomOptions); const roomId = await client.createRoom(roomOptions);
// Store the room mapping // Store the room mapping
await this.prisma.workspace.update({ try {
where: { id: workspaceId }, await this.prisma.workspace.update({
data: { matrixRoomId: roomId }, where: { id: workspaceId },
}); data: { matrixRoomId: roomId },
});
} catch (dbError: unknown) {
this.logger.error(
`Failed to store room mapping for workspace ${workspaceId}, room ${roomId} may be orphaned: ${dbError instanceof Error ? dbError.message : "unknown"}`
);
throw dbError;
}
this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`); this.logger.log(`Matrix room ${roomId} provisioned and linked to workspace ${workspaceId}`);
@@ -134,19 +141,11 @@ export class MatrixRoomService {
} }
/** /**
* Access the underlying MatrixClient from the MatrixService. * Access the underlying MatrixClient from the MatrixService
* * via the public getClient() accessor.
* The MatrixService stores the client as a private field, so we
* access it via a known private property name. This is intentional
* to avoid exposing the client publicly on the service interface.
*/ */
private getMatrixClient(): MatrixClient | null { private getMatrixClient(): MatrixClient | null {
if (!this.matrixService) return null; if (!this.matrixService) return null;
return this.matrixService.getClient();
// Access the private client field from MatrixService.
// MatrixService stores `client` as a private property; we use a type assertion
// to access it since exposing it publicly is not appropriate for the service API.
const service = this.matrixService as unknown as { client: MatrixClient | null };
return service.client;
} }
} }

View File

@@ -195,14 +195,26 @@ export class MatrixStreamingService {
this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`); this.logger.error(`Stream error in room ${roomId}: ${errorMessage}`);
// Edit message to show error // Edit message to show error
const errorContent = accumulatedText try {
? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]` const errorContent = accumulatedText
: `[Streaming error: ${errorMessage}]`; ? `${accumulatedText}\n\n[Streaming error: ${errorMessage}]`
: `[Streaming error: ${errorMessage}]`;
await this.editMessage(roomId, eventId, errorContent); await this.editMessage(roomId, eventId, errorContent);
} catch (editError: unknown) {
this.logger.warn(
`Failed to edit error message in ${roomId}: ${editError instanceof Error ? editError.message : "unknown"}`
);
}
} finally { } finally {
// Step 4: Clear typing indicator // Step 4: Clear typing indicator
await this.setTypingIndicator(roomId, false); try {
await this.setTypingIndicator(roomId, false);
} catch (typingError: unknown) {
this.logger.warn(
`Failed to clear typing indicator in ${roomId}: ${typingError instanceof Error ? typingError.message : "unknown"}`
);
}
} }
// Step 5: Final edit with clean output (if no error) // Step 5: Final edit with clean output (if no error)

View File

@@ -171,6 +171,7 @@ describe("MatrixService", () => {
await service.connect(); await service.connect();
await service.sendThreadMessage({ await service.sendThreadMessage({
threadId: "$root-event-id", threadId: "$root-event-id",
channelId: "!test-room:example.com",
content: "Step completed", content: "Step completed",
}); });
@@ -188,6 +189,28 @@ describe("MatrixService", () => {
}); });
}); });
it("should fall back to controlRoomId when channelId is empty", async () => {
await service.connect();
await service.sendThreadMessage({
threadId: "$root-event-id",
channelId: "",
content: "Fallback message",
});
expect(mockClient.sendMessage).toHaveBeenCalledWith("!test-room:example.com", {
msgtype: "m.text",
body: "Fallback message",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root-event-id",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$root-event-id",
},
},
});
});
it("should throw error when creating thread without connection", async () => { it("should throw error when creating thread without connection", async () => {
await expect( await expect(
service.createThread({ service.createThread({
@@ -202,6 +225,7 @@ describe("MatrixService", () => {
await expect( await expect(
service.sendThreadMessage({ service.sendThreadMessage({
threadId: "$event-id", threadId: "$event-id",
channelId: "!room:example.com",
content: "Test", content: "Test",
}) })
).rejects.toThrow("Matrix client is not connected"); ).rejects.toThrow("Matrix client is not connected");
@@ -764,6 +788,32 @@ describe("MatrixService", () => {
process.env.MATRIX_ACCESS_TOKEN = "test-access-token"; process.env.MATRIX_ACCESS_TOKEN = "test-access-token";
}); });
it("should throw error if MATRIX_BOT_USER_ID is not set", async () => {
delete process.env.MATRIX_BOT_USER_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
MatrixService,
CommandParserService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
{
provide: MatrixRoomService,
useValue: mockMatrixRoomService,
},
],
}).compile();
const newService = module.get<MatrixService>(MatrixService);
await expect(newService.connect()).rejects.toThrow("MATRIX_BOT_USER_ID is required");
// Restore for other tests
process.env.MATRIX_BOT_USER_ID = "@mosaic-bot:example.com";
});
it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => { it("should throw error if MATRIX_WORKSPACE_ID is not set", async () => {
delete process.env.MATRIX_WORKSPACE_ID; delete process.env.MATRIX_WORKSPACE_ID;

View File

@@ -99,6 +99,10 @@ export class MatrixService implements IChatProvider {
throw new Error("MATRIX_WORKSPACE_ID is required"); throw new Error("MATRIX_WORKSPACE_ID is required");
} }
if (!this.botUserId) {
throw new Error("MATRIX_BOT_USER_ID is required");
}
this.logger.log("Connecting to Matrix..."); this.logger.log("Connecting to Matrix...");
const storage = new SimpleFsStorageProvider("matrix-bot-storage.json"); const storage = new SimpleFsStorageProvider("matrix-bot-storage.json");
@@ -129,7 +133,12 @@ export class MatrixService implements IChatProvider {
// Only handle text messages // Only handle text messages
if (event.content.msgtype !== "m.text") return; if (event.content.msgtype !== "m.text") return;
void this.handleRoomMessage(roomId, event); this.handleRoomMessage(roomId, event).catch((error: unknown) => {
this.logger.error(
`Error handling room message in ${roomId}:`,
error instanceof Error ? error.message : error
);
});
}); });
this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => { this.client.on("room.event", (_roomId: string, event: MatrixRoomEvent | null) => {
@@ -332,10 +341,10 @@ export class MatrixService implements IChatProvider {
throw new Error("Matrix client is not connected"); throw new Error("Matrix client is not connected");
} }
const { threadId, content } = options; const { threadId, channelId, content } = options;
// Extract roomId from the control room (threads are room-scoped) // Use the channelId from options (threads are room-scoped), fall back to control room
const roomId = this.controlRoomId; const roomId = channelId || this.controlRoomId;
const threadContent: MatrixMessageContent = { const threadContent: MatrixMessageContent = {
msgtype: "m.text", msgtype: "m.text",
@@ -488,25 +497,38 @@ export class MatrixService implements IChatProvider {
}); });
// Dispatch job to stitcher // Dispatch job to stitcher
const result = await this.stitcherService.dispatchJob({ try {
workspaceId: targetWorkspaceId, const result = await this.stitcherService.dispatchJob({
type: "code-task", workspaceId: targetWorkspaceId,
priority: 10, type: "code-task",
metadata: { priority: 10,
issueNumber, metadata: {
command: "fix", issueNumber,
channelId: message.channelId, command: "fix",
threadId: threadId, channelId: message.channelId,
authorId: message.authorId, threadId: threadId,
authorName: message.authorName, authorId: message.authorId,
}, authorName: message.authorName,
}); },
});
// Send confirmation to thread // Send confirmation to thread
await this.sendThreadMessage({ await this.sendThreadMessage({
threadId, threadId,
content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`, channelId: message.channelId,
}); content: `Job created: ${result.jobId}\nStatus: ${result.status}\nQueue: ${result.queueName}`,
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
this.logger.error(
`Failed to dispatch job for issue #${String(issueNumber)}: ${errorMessage}`
);
await this.sendThreadMessage({
threadId,
channelId: message.channelId,
content: `Failed to start job: ${errorMessage}`,
});
}
} }
/** /**

View File

@@ -101,7 +101,7 @@ describe("HeraldService", () => {
mockPrisma.jobEvent.findFirst.mockResolvedValue({ mockPrisma.jobEvent.findFirst.mockResolvedValue({
payload: { payload: {
metadata: { issueNumber: 42, threadId: "thread-123" }, metadata: { issueNumber: 42, threadId: "thread-123", channelId: "channel-abc" },
}, },
}); });
@@ -126,10 +126,12 @@ describe("HeraldService", () => {
// Assert // Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job created"), content: expect.stringContaining("Job created"),
}); });
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job created"), content: expect.stringContaining("Job created"),
}); });
}); });
@@ -152,10 +154,12 @@ describe("HeraldService", () => {
// Assert // Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job started"), content: expect.stringContaining("Job started"),
}); });
expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderB.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("Job started"), content: expect.stringContaining("Job started"),
}); });
}); });
@@ -178,6 +182,7 @@ describe("HeraldService", () => {
// Assert // Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("completed"), content: expect.stringContaining("completed"),
}); });
}); });
@@ -200,11 +205,13 @@ describe("HeraldService", () => {
// Assert // Assert
expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({ expect(mockProviderA.sendThreadMessage).toHaveBeenCalledWith({
threadId: "thread-123", threadId: "thread-123",
channelId: "channel-abc",
content: expect.stringContaining("encountered an issue"), content: expect.stringContaining("encountered an issue"),
}); });
// Verify the actual message doesn't contain demanding language // Verify the actual message doesn't contain demanding language
const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as { const actualCall = mockProviderA.sendThreadMessage.mock.calls[0][0] as {
threadId: string; threadId: string;
channelId: string;
content: string; content: string;
}; };
expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/); expect(actualCall.content).not.toMatch(/FAILED|ERROR|CRITICAL|URGENT/);

View File

@@ -77,6 +77,7 @@ export class HeraldService {
const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined; const firstEventPayload = firstEvent?.payload as Record<string, unknown> | undefined;
const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined; const metadata = firstEventPayload?.metadata as Record<string, unknown> | undefined;
const threadId = metadata?.threadId as string | undefined; const threadId = metadata?.threadId as string | undefined;
const channelId = metadata?.channelId as string | undefined;
if (!threadId) { if (!threadId) {
this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`); this.logger.debug(`Job ${jobId} has no threadId, skipping broadcast`);
@@ -95,13 +96,15 @@ export class HeraldService {
try { try {
await provider.sendThreadMessage({ await provider.sendThreadMessage({
threadId, threadId,
channelId: channelId ?? "",
content: message, content: message,
}); });
} catch (error) { } catch (error: unknown) {
// Log and continue — one provider failure must not block others // Log and continue — one provider failure must not block others
const providerName = provider.constructor.name;
this.logger.error( this.logger.error(
`Failed to broadcast event ${event.type} for job ${jobId} via provider:`, `Failed to broadcast event ${event.type} for job ${jobId} via ${providerName}:`,
error error instanceof Error ? error.message : error
); );
} }
} }

View File

@@ -112,14 +112,11 @@ echo ""
echo "Step 2: Obtaining admin access token..." echo "Step 2: Obtaining admin access token..."
ADMIN_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \ ADMIN_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "$(jq -n \
\"type\": \"m.login.password\", --arg user "$ADMIN_USERNAME" \
\"identifier\": { --arg pw "$ADMIN_PASSWORD" \
\"type\": \"m.id.user\", '{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
\"user\": \"${ADMIN_USERNAME}\" 2>/dev/null)
},
\"password\": \"${ADMIN_PASSWORD}\"
}" 2>/dev/null)
ADMIN_TOKEN=$(echo "${ADMIN_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) ADMIN_TOKEN=$(echo "${ADMIN_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)
@@ -140,12 +137,11 @@ echo "Step 3: Registering bot account '${BOT_USERNAME}'..."
BOT_REGISTER_RESPONSE=$(curl -sS -X PUT "${SYNAPSE_URL}/_synapse/admin/v2/users/@${BOT_USERNAME}:localhost" \ BOT_REGISTER_RESPONSE=$(curl -sS -X PUT "${SYNAPSE_URL}/_synapse/admin/v2/users/@${BOT_USERNAME}:localhost" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "$(jq -n \
\"password\": \"${BOT_PASSWORD}\", --arg pw "$BOT_PASSWORD" \
\"displayname\": \"${BOT_DISPLAY_NAME}\", --arg dn "$BOT_DISPLAY_NAME" \
\"admin\": false, '{password: $pw, displayname: $dn, admin: false, deactivated: false}')" \
\"deactivated\": false 2>/dev/null)
}" 2>/dev/null)
BOT_EXISTS=$(echo "${BOT_REGISTER_RESPONSE}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('name') else 'no')" 2>/dev/null || echo "no") BOT_EXISTS=$(echo "${BOT_REGISTER_RESPONSE}" | python3 -c "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('name') else 'no')" 2>/dev/null || echo "no")
@@ -162,14 +158,11 @@ echo ""
echo "Step 4: Obtaining bot access token..." echo "Step 4: Obtaining bot access token..."
BOT_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \ BOT_LOGIN_RESPONSE=$(curl -sS -X POST "${SYNAPSE_URL}/_matrix/client/v3/login" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "$(jq -n \
\"type\": \"m.login.password\", --arg user "$BOT_USERNAME" \
\"identifier\": { --arg pw "$BOT_PASSWORD" \
\"type\": \"m.id.user\", '{type: "m.login.password", identifier: {type: "m.id.user", user: $user}, password: $pw}')" \
\"user\": \"${BOT_USERNAME}\" 2>/dev/null)
},
\"password\": \"${BOT_PASSWORD}\"
}" 2>/dev/null)
BOT_TOKEN=$(echo "${BOT_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) BOT_TOKEN=$(echo "${BOT_LOGIN_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true)