* fix(acp): preserve permission choices ACP question bridges encode answers as repeated permission options. Preserve each option through the UI and return the exact selected ID, while keeping auto-accept from choosing on the user's behalf. * fix(acp): validate selected permission behavior Reject inconsistent action IDs instead of allowing the action ID to override the response behavior. Move the misplaced browser spec so the current base CI contract can run. * fix(acp): preserve invalid permission requests Validate selected ACP actions before consuming the pending request so inconsistent client responses can be corrected and retried. * test(acp): verify real permission questions * fix(acp): reject empty permission action ids * test(e2e): scope Kimi config to ACP spec
190 lines
6.6 KiB
TypeScript
190 lines
6.6 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
|
|
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
|
|
|
export interface E2EWorker {
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
function resolveOptionalHome(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return null;
|
|
return resolvePaseoHomePath(trimmed === "current" ? "~/.paseo" : trimmed);
|
|
}
|
|
|
|
async function createFakeEditorBin(): Promise<string> {
|
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
|
let realGhPath = "";
|
|
try {
|
|
realGhPath = execSync("which gh").toString().trim();
|
|
} catch {
|
|
// The local GitHub fixture remains usable without a system gh binary.
|
|
}
|
|
|
|
const fakeEditorSource = `#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
|
if (recordPath) {
|
|
fs.appendFileSync(recordPath, JSON.stringify({
|
|
command: path.basename(process.argv[1]),
|
|
args: process.argv.slice(2),
|
|
cwd: process.cwd(),
|
|
at: Date.now()
|
|
}) + "\\n");
|
|
}
|
|
`;
|
|
for (const editorCommand of ["cursor", "code"]) {
|
|
const editorPath = path.join(binDir, editorCommand);
|
|
await writeFile(editorPath, fakeEditorSource);
|
|
await chmod(editorPath, 0o755);
|
|
}
|
|
|
|
const fakeGhPath = path.join(binDir, "gh");
|
|
const fakeGhSource = `#!/usr/bin/env node
|
|
const { spawnSync } = require("child_process");
|
|
const args = process.argv.slice(2);
|
|
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
|
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"]
|
|
}).stdout?.trim();
|
|
|
|
if (origin === fixtureRemote) {
|
|
const command = args.slice(0, 2).join(" ");
|
|
if (command === "auth status") process.exit(0);
|
|
if (command === "repo view") {
|
|
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
|
process.exit(0);
|
|
}
|
|
if (command === "issue list") {
|
|
process.stdout.write("[]");
|
|
process.exit(0);
|
|
}
|
|
if (command === "pr list" || command === "pr view") {
|
|
const pr = {
|
|
number: 1,
|
|
title: "Use pasted PR as start ref",
|
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
|
state: "OPEN",
|
|
body: null,
|
|
labels: [],
|
|
baseRefName: "main",
|
|
headRefName: "pr-branch-1",
|
|
updatedAt: "2026-01-01T00:00:00Z"
|
|
};
|
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
|
process.exit(0);
|
|
}
|
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
|
process.stdout.write(JSON.stringify({
|
|
data: { repository: { pullRequest: {
|
|
number: 1,
|
|
baseRefName: "main",
|
|
headRefName: "pr-branch-1",
|
|
isCrossRepository: false,
|
|
headRepositoryOwner: { login: "paseo-e2e" },
|
|
headRepository: {
|
|
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
|
url: fixtureRemote
|
|
}
|
|
} } }
|
|
}));
|
|
process.exit(0);
|
|
}
|
|
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const realGhPath = ${JSON.stringify(realGhPath)};
|
|
if (!realGhPath) process.exit(127);
|
|
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
|
process.exit(result.status ?? 1);
|
|
`;
|
|
await writeFile(fakeGhPath, fakeGhSource);
|
|
await chmod(fakeGhPath, 0o755);
|
|
return binDir;
|
|
}
|
|
|
|
async function applyMetadataFork(targetHome: string, providerIds: string[]): Promise<void> {
|
|
const sourceHome = resolveOptionalHome(process.env.E2E_FORK_PASEO_HOME_FROM);
|
|
if (!sourceHome) return;
|
|
const result = await forkPaseoHomeMetadata({ sourceHome, targetHome });
|
|
process.env.E2E_FORK_SOURCE_PASEO_HOME = result.sourceHome;
|
|
process.env.E2E_FORK_TARGET_PASEO_HOME = result.targetHome;
|
|
process.env.E2E_FORK_COPIED_FILES = String(result.copiedFiles);
|
|
process.env.E2E_FORK_COPIED_BYTES = String(result.copiedBytes);
|
|
|
|
if (providerIds.length === 0) return;
|
|
|
|
const sourceConfig = JSON.parse(
|
|
await readFile(path.join(result.sourceHome, "config.json"), "utf8"),
|
|
);
|
|
const sourceProviders = sourceConfig.agents?.providers ?? {};
|
|
const providers = Object.fromEntries(
|
|
providerIds.map((providerId: string) => {
|
|
const provider = sourceProviders[providerId];
|
|
if (!provider) {
|
|
throw new Error(`E2E provider '${providerId}' is not configured in ${result.sourceHome}`);
|
|
}
|
|
return [providerId, provider];
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(targetHome, "config.json"),
|
|
`${JSON.stringify({ version: 1, agents: { providers } }, null, 2)}\n`,
|
|
);
|
|
}
|
|
|
|
export async function startE2EWorker(
|
|
workerIndex: number,
|
|
options: { forkProviders?: string[] } = {},
|
|
): Promise<E2EWorker> {
|
|
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
|
const paseoHome = requestedRoot
|
|
? path.join(requestedRoot, `worker-${workerIndex}`)
|
|
: await mkdtemp(path.join(tmpdir(), `paseo-e2e-worker-${workerIndex}-`));
|
|
const preserveHome = Boolean(requestedRoot) || process.env.E2E_KEEP_PASEO_HOME === "1";
|
|
const fakeEditorBin = await createFakeEditorBin();
|
|
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
|
const serverId = `srv_e2e_worker_${workerIndex}`;
|
|
|
|
try {
|
|
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
|
const daemon = await startIsolatedHostDaemon(serverId, {
|
|
paseoHome,
|
|
preserveHome,
|
|
environment: {
|
|
NODE_ENV: "development",
|
|
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
|
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
|
},
|
|
});
|
|
|
|
process.env.E2E_DAEMON_PORT = String(daemon.port);
|
|
process.env.E2E_SERVER_ID = daemon.serverId;
|
|
process.env.E2E_PASEO_HOME = daemon.paseoHome;
|
|
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
|
delete process.env.E2E_RELAY_PORT;
|
|
delete process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
|
|
|
console.log(
|
|
`[e2e] Worker ${workerIndex} daemon started on port ${daemon.port}, home: ${daemon.paseoHome}`,
|
|
);
|
|
return {
|
|
close: async () => {
|
|
await daemon.close();
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
console.log(`[e2e] Worker ${workerIndex} daemon stopped`);
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await rm(fakeEditorBin, { recursive: true, force: true });
|
|
if (!preserveHome) await rm(paseoHome, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|