e62db981e8
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 54s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m3s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 59s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 58s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 6s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
gate-check-v3 / gate-check (pull_request) Successful in 20s
qa-review / approved (pull_request) Failing after 14s
sop-checklist / all-items-acked (pull_request) Successful in 5s
sop-checklist / review-refire (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 53s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m21s
sop-tier-check / tier-check (pull_request) Successful in 6s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 35s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 40s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m28s
CI / Platform (Go) (pull_request) Successful in 4m54s
Harness Replays / Harness Replays (pull_request) Successful in 6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m1s
E2E Chat / E2E Chat (pull_request) Failing after 3m5s
CI / Canvas (Next.js) (pull_request) Successful in 6m58s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 6m48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m57s
181 lines
6.0 KiB
TypeScript
181 lines
6.0 KiB
TypeScript
/**
|
|
* Minimal A2A echo runtime for E2E tests.
|
|
*
|
|
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
|
|
* requests, and returns a response with the original text echoed back.
|
|
* Also implements the workspace-side chat upload ingest endpoint so
|
|
* file-attachment E2E can exercise the full upload → send → echo
|
|
* round-trip.
|
|
*
|
|
* Usage (inside test fixture):
|
|
* const echo = await startEchoRuntime();
|
|
* // ... seed workspace with agent_url pointing to echo.baseURL ...
|
|
* echo.stop();
|
|
*/
|
|
|
|
import { createServer, type Server } from "node:http";
|
|
|
|
export interface EchoRuntime {
|
|
baseURL: string;
|
|
stop: () => Promise<void>;
|
|
lastRequest: { method: string; text: string; files: unknown[] } | null;
|
|
}
|
|
|
|
/** Parse a minimal multipart body and extract the first file's name + content. */
|
|
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
|
|
// Find the boundary line (first line starting with "--").
|
|
const str = body.toString("binary");
|
|
const firstDash = str.indexOf("--");
|
|
if (firstDash === -1) return null;
|
|
const eol = str.indexOf("\r\n", firstDash);
|
|
if (eol === -1) return null;
|
|
const boundary = str.slice(firstDash + 2, eol);
|
|
const boundaryMarker = "\r\n--" + boundary;
|
|
|
|
// Find the first part that has a filename in Content-Disposition.
|
|
let pos = eol + 2;
|
|
while (pos < str.length) {
|
|
const nextBoundary = str.indexOf(boundaryMarker, pos);
|
|
if (nextBoundary === -1) break;
|
|
const part = str.slice(pos, nextBoundary);
|
|
|
|
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
|
|
if (cdMatch) {
|
|
const name = cdMatch[1];
|
|
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
|
|
// Body starts after the first double-CRLF in the part.
|
|
const bodyStart = part.indexOf("\r\n\r\n");
|
|
if (bodyStart !== -1) {
|
|
// Extract the raw bytes (not the string) so binary is safe.
|
|
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
|
|
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
|
|
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
|
|
const content = body.subarray(partStartInBody, partEndInBody);
|
|
return { name, mimeType, content };
|
|
}
|
|
}
|
|
pos = nextBoundary + boundaryMarker.length;
|
|
// Skip trailing "--" (end marker) or CRLF.
|
|
if (str.slice(pos, pos + 2) === "--") break;
|
|
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function startEchoRuntime(): Promise<EchoRuntime> {
|
|
let lastRequest: EchoRuntime["lastRequest"] = null;
|
|
|
|
const server = createServer((req, res) => {
|
|
// CORS: allow the canvas origin (localhost:3000) to call us.
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const url = req.url ?? "/";
|
|
|
|
// Workspace-side chat upload ingest (RFC #2312).
|
|
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => {
|
|
const body = Buffer.concat(chunks);
|
|
const file = parseMultipart(body);
|
|
if (!file) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "no files field" }));
|
|
return;
|
|
}
|
|
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
|
|
const prefix = Array.from({ length: 32 }, () =>
|
|
Math.floor(Math.random() * 16).toString(16),
|
|
).join("");
|
|
const response = {
|
|
files: [
|
|
{
|
|
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
|
|
name: sanitized,
|
|
mimeType: file.mimeType,
|
|
size: file.content.length,
|
|
},
|
|
],
|
|
};
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(response));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Default: A2A JSON-RPC handler.
|
|
let body = "";
|
|
req.setEncoding("utf8");
|
|
req.on("data", (chunk: string) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
res.setHeader("Content-Type", "application/json");
|
|
try {
|
|
const rpc = JSON.parse(body);
|
|
const msg = rpc.params?.message;
|
|
const textParts =
|
|
msg?.parts
|
|
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
|
|
.map((p: { text?: string }) => p.text)
|
|
.filter(Boolean) ?? [];
|
|
const fileParts =
|
|
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
|
|
const text = textParts.join("\n");
|
|
|
|
lastRequest = {
|
|
method: rpc.method ?? "unknown",
|
|
text,
|
|
files: fileParts,
|
|
};
|
|
|
|
const replyText = text
|
|
? `Echo: ${text}`
|
|
: fileParts.length > 0
|
|
? "Echo: received your file(s)."
|
|
: "Echo: hello";
|
|
|
|
const response = {
|
|
jsonrpc: "2.0",
|
|
id: rpc.id ?? null,
|
|
result: {
|
|
parts: [{ kind: "text", text: replyText }],
|
|
},
|
|
};
|
|
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(response));
|
|
} catch {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: "invalid json" }));
|
|
}
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
const baseURL = `http://localhost:${port}`;
|
|
|
|
return {
|
|
baseURL,
|
|
stop: () =>
|
|
new Promise((resolve) => {
|
|
server.close(() => resolve(undefined));
|
|
}),
|
|
get lastRequest() {
|
|
return lastRequest;
|
|
},
|
|
};
|
|
}
|