Compare commits

..
Author SHA1 Message Date
morlutoandGitHub 49ada44d9b Merge branch 'main' into cursor/browser-cancellation-after-cleanup-1ffc 2026-10-14 03:06:36 +08:00
morlutoandGitHub 1ed755ac60 Merge branch 'main' into cursor/browser-cancellation-after-cleanup-1ffc 2026-10-14 02:44:24 +08:00
morluto bfcc9526a3 fix(browser): preserve source-map cancellation results 2026-10-14 01:56:05 +08:00
REA Maintenance a742936108 Merge commit 'ebaaa79ca02bde4a73013a2a9a781d0b07a57ee4' into HEAD
# Conflicts:
#	src/browser/CdpBrowserProvider.ts
#	tests/boundary/cli/clientProfileLifecycle.test.ts
2026-10-14 01:31:47 +08:00
Cursor Agent 82bcf0814d test(cli): type the retained skill directory pairs
noUncheckedIndexedAccess treats the unlabeled pair list as an open array, so the directory passed to join was string or undefined.
2026-10-13 16:02:11 +00:00
Cursor Agent 474b34c570 test(ghidra): drop the duplicated path join import
The inventory MCP test imported join twice, which fails lint on current main.
2026-10-13 16:00:50 +00:00
Cursor Agent de1d3f2a24 test(browser): keep cleanup cancellation coverage in its own suite
The added interrupt cases made the existing lifecycle suite exceed the function-length lint limit.
2026-10-13 16:00:49 +00:00
Cursor Agent 0e92dd8e60 fix(setup): keep Devin configuration JSON-formatted
The empty DSH_HOME fix also changed Devin's declared format to "devin", which is not a configuration format and stops the typecheck. Devin still writes mcp_config.json.
2026-10-13 15:59:23 +00:00
Cursor Agent d32287ebf6 fix(browser): report cancellation after cleanup and source-map abort
An interrupt during CDP cleanup could still publish the captured page, and a source-map download abort was wrapped as a provider failure. Both now return AnalysisCancelledError for the requested operation.
2026-10-13 15:56:56 +00:00
10 changed files with 237 additions and 117 deletions
-6
View File
@@ -8,10 +8,4 @@ export function assertLinuxPackageProviderFailure(execution: {
export function verifyPackagePlatform(input: {
readonly cli: string;
readonly environment: Readonly<Record<string, string | undefined>>;
readonly platform?: string;
readonly runCommand?: (
command: string,
arguments_: readonly string[],
environment: Readonly<Record<string, string | undefined>>,
) => Promise<string>;
}): Promise<void>;
+15 -29
View File
@@ -1,13 +1,8 @@
import { json, run, runWithStatus } from "./lib/verify-package-core.mjs";
/** Run platform-specific deep-analysis smoke tests. */
export async function verifyPackagePlatform({
cli,
environment,
platform = process.platform,
runCommand = run,
}) {
if (platform === "linux") {
export async function verifyPackagePlatform({ cli, environment }) {
if (process.platform === "linux") {
const unsupportedExecution = await runWithStatus(
cli,
["analyze", process.execPath, "--json"],
@@ -16,27 +11,25 @@ export async function verifyPackagePlatform({
assertLinuxPackageProviderFailure(unsupportedExecution);
} else {
const overview = json(
await runCommand(
cli,
["analyze", process.execPath, "--json"],
environment,
),
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (!hasPositiveProcedureCount(overview))
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await runCommand(
cli,
["inspect", process.execPath, "--json"],
environment,
),
await run(cli, ["inspect", process.execPath, "--json"], environment),
);
if (!hasPositiveProcedureCount(inspected))
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.procedure_count < 1
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await runCommand(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
@@ -51,7 +44,7 @@ export async function verifyPackagePlatform({
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await runCommand(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
@@ -63,7 +56,7 @@ export async function verifyPackagePlatform({
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await runCommand(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
@@ -100,10 +93,3 @@ export const assertLinuxPackageProviderFailure = (execution) => {
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`,
);
};
const hasPositiveProcedureCount = (result) =>
typeof result === "object" &&
result !== null &&
result.operation === "binary_overview" &&
Number.isInteger(result.normalized_result?.procedure_count) &&
result.normalized_result.procedure_count > 0;
+10 -1
View File
@@ -43,7 +43,10 @@ import {
type WebScreenshotDiff,
} from "../domain/webScreenshot.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/analysisErrorCore.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
@@ -164,6 +167,8 @@ export class CdpBrowserProvider implements BrowserObservationPort {
),
);
} catch (cause: unknown) {
if (options.signal?.aborted === true)
return err(new AnalysisCancelledError("analyze_web_bundle", { cause }));
return err(providerError(cause, "analyze_web_bundle"));
}
}
@@ -335,6 +340,10 @@ const withTargetSession = async <
);
// An unadopted browser socket is closed here; closing is idempotent.
await endpoint.connection?.close();
// Cleanup can observe an interrupt after work returns. Do not publish its
// result once cancellation arrived while the target session was closing.
if (options.signal?.aborted === true)
throw new AnalysisCancelledError(operation);
}
};
+3 -18
View File
@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import {
TOOL_EFFECTS,
annotationsFromEffects,
type ToolEffects,
} from "./toolEffects.js";
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
/**
* Descriptions that name running, launching, executing, starting, installing,
@@ -152,19 +148,8 @@ describe("tool effects", () => {
});
it("derives destructiveHint from each destructive effect alone", () => {
// Keep annotation algebra independent of any operation's audited effects.
const base: ToolEffects = {
mutatesTarget: false,
mutatesSession: false,
writesFilesystem: false,
launchesProcess: false,
accessesNetwork: false,
changesUiState: false,
mayDiscardData: false,
executesForeignCode: false,
mutatesDevice: false,
idempotent: true,
};
const base = TOOL_EFFECTS["binary_session"];
if (base === undefined) throw new Error("binary_session is unaudited");
expect(annotationsFromEffects(base)).toEqual({
readOnlyHint: true,
destructiveHint: false,
+1 -1
View File
@@ -605,7 +605,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mayDiscardData: true,
idempotent: false,
}),
binary_session: effects({ launchesProcess: true }),
binary_session: effects(),
export_evidence_bundle: effects({
writesFilesystem: true,
mayDiscardData: true,
@@ -7,12 +7,205 @@ import {
} from "../../../src/domain/browserObservation.js";
import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js";
import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js";
import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js";
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
import {
startFakeCdpBrowser,
type FakeCdpBrowser,
} from "../../fixtures/fakeCdpBrowser.js";
import { trackBrowser } from "./cdpBrowserProvider.support.js";
const waitFor = async (ready: () => boolean, description: string) => {
const deadline = Date.now() + 5_000;
while (!ready()) {
if (Date.now() > deadline) throw new Error(description);
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
describe("CdpBrowserProvider: cancellation during cleanup", () => {
it.each([
{
name: "WebMCP discovery",
operation: "discover_webmcp_tools",
invoke: (
provider: CdpBrowserProvider,
browser: FakeCdpBrowser,
signal: AbortSignal,
) =>
provider.discoverWebMcpTools(
discoverWebMcpToolsInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
}),
{ signal },
),
},
{
name: "screenshot capture",
operation: "capture_web_screenshot",
invoke: (
provider: CdpBrowserProvider,
browser: FakeCdpBrowser,
signal: AbortSignal,
) =>
provider.captureScreenshot(
captureWebScreenshotInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
}),
{ signal },
),
},
])(
"reports cancellation during $name cleanup",
async ({ operation, invoke }) => {
const browser = await startFakeCdpBrowser({
hangOnMethod: "Page.disable",
webMcpTools: true,
});
trackBrowser(browser);
const controller = new AbortController();
const pending = invoke(
new CdpBrowserProvider(),
browser,
controller.signal,
);
await waitFor(
() => browser.commands.some(({ method }) => method === "Page.disable"),
"page cleanup did not start",
);
controller.abort();
await expect(pending).resolves.toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", operation },
});
},
);
it("reports cancellation when an interrupt arrives during page cleanup", async () => {
const browser = await startFakeCdpBrowser({
hangOnMethod: "Network.disable",
});
trackBrowser(browser);
const controller = new AbortController();
const pending = new CdpBrowserProvider().inspectPage(
inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
}),
{ signal: controller.signal },
);
await waitFor(
() => browser.commands.some(({ method }) => method === "Network.disable"),
"page cleanup did not start",
);
controller.abort();
await expect(pending).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "inspect_web_page",
},
});
});
it("reports cancellation when an interrupt arrives during session cleanup", async () => {
const browser = await startFakeCdpBrowser({
hangOnMethod: "Network.disable",
});
trackBrowser(browser);
const controller = new AbortController();
const pending = new CdpBrowserProvider().observeSession(
observeWebSessionInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 1,
}),
{ signal: controller.signal },
);
await waitFor(
() => browser.commands.some(({ method }) => method === "Network.disable"),
"session cleanup did not start",
);
controller.abort();
await expect(pending).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "observe_web_session",
},
});
});
it("reports cancellation when an interrupt arrives during source-map download", async () => {
const browser = await startFakeCdpBrowser({ hangSourceMap: true });
trackBrowser(browser);
const controller = new AbortController();
const pending = new CdpBrowserProvider().analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
fetch_source_maps: true,
}),
{ signal: controller.signal },
);
await waitFor(
() =>
browser.httpRequests.some(({ url }) => url.startsWith("/app.js.map")),
"source-map download did not start",
);
controller.abort();
await expect(pending).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "analyze_web_bundle",
},
});
});
it("classifies custom source-map abort reasons as cancellation", async () => {
const browser = await startFakeCdpBrowser({ hangSourceMap: true });
trackBrowser(browser);
const controller = new AbortController();
const pending = new CdpBrowserProvider().analyzeBundle(
analyzeWebBundleInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
fetch_source_maps: true,
}),
{ signal: controller.signal },
);
await waitFor(
() =>
browser.httpRequests.some(({ url }) => url.startsWith("/app.js.map")),
"source-map download did not start",
);
controller.abort(new Error("stopped"));
await expect(pending).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "analyze_web_bundle",
},
});
});
});
describe("CdpBrowserProvider: cancellation and session events", () => {
it("honors cancellation during the final progress callback before publishing inspection", async () => {
const browser = await startFakeCdpBrowser();
@@ -73,7 +73,13 @@ it("releases cleanup when cancellation arrives after capture completes", async (
});
controller.abort();
const result = await pending;
expect(result.ok).toBe(true);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "observe_web_session",
},
});
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.detachFromTarget",
);
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
assertLinuxPackageProviderFailure,
verifyPackagePlatform,
} from "../../../scripts/verify-package-platform.mjs";
import { assertLinuxPackageProviderFailure } from "../../../scripts/verify-package-platform.mjs";
const missingDisplay = {
code: "provider_unavailable",
@@ -25,37 +22,6 @@ const execution = (failure: unknown, status = 1) => ({
stdout: JSON.stringify(failure),
});
const overview = (procedure_count?: unknown, operation = "binary_overview") => ({
operation,
...(procedure_count === undefined
? {}
: { normalized_result: { procedure_count } }),
});
const verifyMacOS = async (
analyze: unknown,
inspect: unknown,
): Promise<void> => {
const outputs: Readonly<Record<string, unknown>> = {
analyze,
inspect,
function: {
operation: "analyze_function",
provider: { id: "hopper" },
normalized_result: { procedure: { address: "0x1000" } },
},
xrefs: { operation: "xrefs", normalized_result: ["0x1000"] },
trace: { operation: "trace_feature" },
};
await verifyPackagePlatform({
cli: "fixture-cli",
environment: {},
platform: "darwin",
runCommand: async (_cli, arguments_) =>
JSON.stringify(outputs[arguments_[0]!]) ?? "",
});
};
describe("packaged Linux failure verification", () => {
it("accepts the unsupported fixture build or an exact missing display dependency", () => {
expect(() =>
@@ -105,29 +71,3 @@ describe("packaged Linux failure verification", () => {
},
);
});
describe("packaged analyze and inspect result guards", () => {
it("accepts positive integer procedure counts for both commands", async () => {
await verifyMacOS(overview(1), overview(2));
});
const malformed = [
["missing result", undefined],
["null result", null],
["missing normalized count", overview()],
["zero count", overview(0)],
["non-integer count", overview(1.5)],
["wrong operation", overview(2, "other_operation")],
] as const;
it.each(["analyze", "inspect"] as const)(
"rejects malformed %s results before trusting the package",
async (command) => {
for (const [label, invalid] of malformed) {
const analyze = command === "analyze" ? invalid : overview(4);
const inspect = command === "inspect" ? invalid : overview(4);
await expect(verifyMacOS(analyze, inspect), label).rejects.toThrow();
}
},
);
});
+5
View File
@@ -229,6 +229,11 @@ const serveSourceMap = (
response: ServerResponse,
options: FakeOptions,
): boolean => {
if (
options.hangSourceMap === true &&
request.url?.startsWith("/app.js.map") === true
)
return true;
if (options.sourceMapBody === undefined) return false;
if (request.url?.startsWith("/app.js.map") === true) {
if (options.sourceMapRedirect !== undefined)
+2
View File
@@ -95,6 +95,8 @@ export interface FakeOptions {
readonly webSocketWithoutFrames?: boolean;
readonly invalidBinaryWebSocketEvent?: boolean;
readonly sourceMapBody?: string;
/** Accept a source-map request and leave the response open. */
readonly hangSourceMap?: boolean;
readonly sourceMapRedirect?: string;
readonly sessionTimeline?:
| "same_origin"