Skip to content

Commit 3f1f9f6

Browse files
committed
Surface unreachable upstreams as network error
1 parent 2dc399e commit 3f1f9f6

4 files changed

Lines changed: 1077 additions & 811 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"executor": patch
3+
"@executor-js/plugin-openapi": patch
4+
---
5+
6+
OpenAPI tools that cannot reach the upstream server now return an `upstream_unreachable` error with an actionable network message instead of `Internal tool error [id]`.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Cross-target: an artifact whose OpenAPI query cannot reach its upstream gets
2+
// an actionable network error, not the opaque defect mask. This walks the real
3+
// path from a saved artifact through the nested shell, execute-action, sandbox,
4+
// OpenAPI transport, and back into ArtifactError.
5+
import { randomBytes } from "node:crypto";
6+
import { createServer } from "node:http";
7+
8+
import { expect } from "@effect/vitest";
9+
import { Effect } from "effect";
10+
import type { Page } from "playwright";
11+
import { composePluginApi } from "@executor-js/api/server";
12+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13+
import { ConnectionName, IntegrationSlug, type ArtifactId } from "@executor-js/sdk/shared";
14+
15+
import { scenario } from "../src/scenario";
16+
import { Api, Browser, Mcp, Target } from "../src/services";
17+
import { visit } from "../src/surfaces/browser";
18+
import type { McpSession } from "../src/surfaces/mcp";
19+
20+
const api = composePluginApi([openApiHttpPlugin()] as const);
21+
22+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
23+
24+
type DroppingUpstream = {
25+
readonly url: string;
26+
readonly requests: () => number;
27+
readonly close: () => void;
28+
};
29+
30+
// Accept the request, then drop the socket before sending response headers.
31+
// This produces a real transport failure without relying on a hardcoded or
32+
// temporarily-unused port.
33+
const serveDroppingUpstream = () =>
34+
Effect.acquireRelease(
35+
Effect.callback<DroppingUpstream>((resume) => {
36+
let hits = 0;
37+
const server = createServer((_request, response) => {
38+
hits += 1;
39+
response.destroy();
40+
});
41+
server.listen(0, "127.0.0.1", () => {
42+
const address = server.address();
43+
const port = typeof address === "object" && address ? address.port : 0;
44+
resume(
45+
Effect.succeed({
46+
url: `http://127.0.0.1:${port}`,
47+
requests: () => hits,
48+
close: () => {
49+
server.close();
50+
server.closeAllConnections();
51+
},
52+
}),
53+
);
54+
});
55+
}),
56+
(server) => Effect.sync(server.close),
57+
);
58+
59+
const unreachableSpec = (baseUrl: string): string =>
60+
JSON.stringify({
61+
openapi: "3.0.3",
62+
info: { title: "Unreachable API", version: "1.0.0" },
63+
servers: [{ url: baseUrl }],
64+
paths: {
65+
"/things": {
66+
get: {
67+
tags: ["things"],
68+
operationId: "listThings",
69+
summary: "List things",
70+
responses: {
71+
"200": {
72+
description: "Things",
73+
content: {
74+
"application/json": {
75+
schema: { type: "array", items: { type: "object" } },
76+
},
77+
},
78+
},
79+
},
80+
},
81+
},
82+
},
83+
});
84+
85+
const createConnectionCode = (slug: string) => `
86+
const created = await tools.executor.coreTools.connections.create({
87+
owner: "org",
88+
name: "public",
89+
integration: ${JSON.stringify(slug)},
90+
template: "none",
91+
});
92+
return JSON.stringify(created.ok ? { ok: true } : { ok: false, error: created.error });
93+
`;
94+
95+
const executeApproved = (session: McpSession, code: string) =>
96+
Effect.gen(function* () {
97+
let result = yield* session.call("execute", { code });
98+
let guard = 0;
99+
while (result.text.includes("executionId:") && guard < 10) {
100+
result = yield* session.approvePaused(result.text);
101+
guard += 1;
102+
}
103+
expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
104+
return result.text;
105+
});
106+
107+
const artifactSource = (slug: string) => `
108+
function App() {
109+
const query = useQuery(tools.${slug}.things.listThings.queryOptions({}));
110+
const result = query.data;
111+
return (
112+
<div className="flex h-full flex-col gap-4">
113+
<h2>Upstream status</h2>
114+
<div data-testid="upstream-state" className="min-h-0 flex-1">
115+
{query.isLoading ? (
116+
<ArtifactLoading />
117+
) : query.error ? (
118+
<ArtifactError error={query.error} onRetry={query.refetch} />
119+
) : result?.ok === false ? (
120+
<ArtifactError error={result.error} onRetry={query.refetch} />
121+
) : (
122+
<p>Unexpected upstream success</p>
123+
)}
124+
</div>
125+
</div>
126+
);
127+
}
128+
`;
129+
130+
const structuredOf = (result: { readonly raw: unknown }): Record<string, unknown> =>
131+
((result.raw as { structuredContent?: Record<string, unknown> }).structuredContent ??
132+
{}) as Record<string, unknown>;
133+
134+
const artifactContent = (page: Page) =>
135+
page.frameLocator('[data-testid="artifact-shell-frame"]').frameLocator("iframe");
136+
137+
scenario(
138+
"Artifacts · an unreachable OpenAPI host shows actionable retry guidance instead of an internal error",
139+
{ timeout: 180_000 },
140+
Effect.scoped(
141+
Effect.gen(function* () {
142+
const target = yield* Target;
143+
const browser = yield* Browser;
144+
const mcp = yield* Mcp;
145+
const { client: makeClient } = yield* Api;
146+
147+
const identity = yield* target.newIdentity();
148+
const client = yield* makeClient(api, identity);
149+
const session = mcp.session(identity);
150+
const upstream = yield* serveDroppingUpstream();
151+
const slug = unique("unreachable");
152+
const title = `Unreachable upstream ${randomBytes(4).toString("hex")}`;
153+
let artifactId: ArtifactId | undefined;
154+
155+
yield* Effect.ensuring(
156+
Effect.gen(function* () {
157+
yield* client.openapi.addSpec({
158+
payload: {
159+
spec: { kind: "blob", value: unreachableSpec(upstream.url) },
160+
slug,
161+
baseUrl: upstream.url,
162+
},
163+
});
164+
165+
const created = yield* executeApproved(session, createConnectionCode(slug));
166+
expect(created, `the no-auth connection was created: ${created}`).toContain('"ok":true');
167+
168+
const rendered = yield* session.call("create-artifact", {
169+
code: artifactSource(slug),
170+
title,
171+
description: "Shows whether the upstream API is reachable",
172+
connections: { [slug]: `${slug}.org.public` },
173+
});
174+
expect(rendered.ok, `create-artifact succeeded: ${rendered.text}`).toBe(true);
175+
176+
const structured = structuredOf(rendered);
177+
artifactId = structured.artifactId as ArtifactId;
178+
expect(artifactId, "the artifact was persisted").toBeTruthy();
179+
180+
yield* browser.session(identity, async ({ page, step }) => {
181+
await step("Open the artifact that reads from the unreachable API", async () => {
182+
await visit(page, String(structured.url));
183+
await page.getByRole("heading", { name: title }).waitFor({ timeout: 20_000 });
184+
});
185+
186+
await step(
187+
"The artifact explains that the upstream host could not be reached",
188+
async () => {
189+
const state = artifactContent(page).getByTestId("upstream-state");
190+
await state.locator('[data-slot="artifact-error"]').waitFor({ timeout: 30_000 });
191+
const message = await state.innerText();
192+
193+
expect(message, "the user gets actionable network guidance").toContain(
194+
"Could not reach the upstream server",
195+
);
196+
expect(message, "the opaque defect mask never reaches the artifact").not.toContain(
197+
"Internal tool error",
198+
);
199+
expect(message, "the request path is not leaked").not.toContain("/things");
200+
},
201+
);
202+
});
203+
204+
expect(upstream.requests(), "the artifact made a real upstream request").toBeGreaterThan(
205+
0,
206+
);
207+
}),
208+
Effect.gen(function* () {
209+
if (artifactId !== undefined) {
210+
yield* client.artifacts.remove({ params: { artifactId } }).pipe(Effect.ignore);
211+
}
212+
yield* client.connections
213+
.remove({
214+
params: {
215+
owner: "org",
216+
integration: IntegrationSlug.make(slug),
217+
name: ConnectionName.make("public"),
218+
},
219+
})
220+
.pipe(Effect.ignore);
221+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
222+
}),
223+
);
224+
}),
225+
),
226+
);

0 commit comments

Comments
 (0)