// --------------------------------------------------------------------------- // Local app auth failure propagation // --------------------------------------------------------------------------- // // Exercises the local HTTP API boundary: // // test -> HttpApiClient -> in-process LocalApi -> execution engine // -> sandbox code -> OpenAPI tool invocation // // The assertion is intentionally on the final execution payload, not the // plugin facade, so reviewers can see that model-visible tool results carry // auth guidance instead of an opaque internal tool error. // // v2: a connection IS the credential. addSpec registers the integration with an // apiKey auth template; a connection is then created whose value cannot resolve // (a `from` reference to a missing provider item). Invoking one of that // connection's tools surfaces `connection_value_missing` to the model. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; import { randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Layer, Schema } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { HttpApi, HttpApiBuilder, HttpApiClient, HttpApiEndpoint, HttpApiGroup, } from "effect/unstable/httpapi"; import { addGroup, observabilityMiddleware } from "@executor-js/api"; import { CoreHandlers, ExecutionEngineService, ExecutorService, collectTables, } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { openApiPlugin } from "@executor-js/plugin-openapi"; import { OpenApiExtensionService, OpenApiGroup, OpenApiHandlers, } from "@executor-js/plugin-openapi/api"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, ProviderItemId, ProviderKey, Subject, Tenant, createExecutor, } from "@executor-js/sdk"; import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { ErrorCaptureLive } from "./observability"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; const TEST_BASE_URL = "http://local.test"; const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), ); const MissingAuthSourceApi = HttpApi.make("localAuthFailureSource").add(PingGroup); const TestApi = addGroup(OpenApiGroup); type TestApiShape = typeof TestApi extends HttpApi.HttpApi ? HttpApiClient.Client : never; const API_KEY_TEMPLATE = "apiKey"; interface Harness { readonly fetch: typeof globalThis.fetch; readonly addConnection: (input: { readonly integration: string; readonly connection: string; }) => Promise; readonly dispose: () => Promise; } const startHarness = async (tmpDir: string): Promise => { const plugins = [ openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), fileSecretsPlugin({ directory: tmpDir }), memoryCredentialsPlugin(), ] as const; const sqlite = await createSqliteFumaDb({ tables: collectTables(), namespace: "executor_local_auth_tool_failures_test", path: join(tmpDir, "data.db"), }); const executor = await Effect.runPromise( createExecutor({ tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", }), ); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), }); const TestObservability = observabilityMiddleware(TestApi); const TestApiBase = HttpApiBuilder.layer(TestApi).pipe( Layer.provide(CoreHandlers), Layer.provide(OpenApiHandlers), Layer.provide(TestObservability), Layer.provide(ErrorCaptureLive), ); const { handler: webHandler, dispose: disposeHandler } = HttpRouter.toWebHandler( TestApiBase.pipe( Layer.provideMerge(Layer.succeed(OpenApiExtensionService)(executor.openapi)), Layer.provideMerge(Layer.succeed(ExecutorService)(executor)), Layer.provideMerge(Layer.succeed(ExecutionEngineService)(engine)), Layer.provideMerge(HttpServer.layerServices), Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), ), ); return { fetch: ((input: RequestInfo | URL, init?: RequestInit) => webHandler( input instanceof Request ? input : new Request(input, init), )) as typeof globalThis.fetch, // Create an org connection whose value cannot resolve: a `from` reference // to a memory-provider item that was never stored resolves to `null`, so // tool invocation surfaces `connection_value_missing`. addConnection: (input) => Effect.runPromise( executor.connections .create({ owner: "org", name: ConnectionName.make(input.connection), integration: IntegrationSlug.make(input.integration), template: AuthTemplateSlug.make(API_KEY_TEMPLATE), from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make(`${input.integration}-missing`), }, }) .pipe(Effect.asVoid), ), dispose: async () => { await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); await Effect.runPromise( Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), ); await sqlite.close(); }, }; }; const run = (body: (client: TestApiShape) => Effect.Effect): Effect.Effect => Effect.gen(function* () { const client = yield* HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL, }); return yield* body(client); }).pipe( Effect.provide( FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), ), ), ) as Effect.Effect; type EffectSuccess = T extends Effect.Effect ? A : never; type ExecuteResult = EffectSuccess>; const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { expect(execution.status).toBe("completed"); if (execution.status !== "completed") return; expect(execution.isError).toBe(false); expect(JSON.stringify(execution.structured)).not.toContain("Internal tool error"); expect(JSON.stringify(execution.structured)).not.toContain("Internal Tool Error"); expect(execution.structured).toMatchObject({ status: "completed", result: { ok: false, error: { code: "connection_value_missing", details: { category: "authentication", recovery: { createConnectionTool: "executor.coreTools.connections.createHandoff", listConnectionsTool: "executor.coreTools.connections.list", }, }, }, }, }); }; let tmpDir: string; let harness: Harness; beforeAll(async () => { tmpDir = mkdtempSync(join(tmpdir(), "executor-local-auth-tool-failures-")); harness = await startHarness(tmpDir); }); afterAll(async () => { await harness.dispose(); rmSync(tmpDir, { recursive: true, force: true }); }); describe("local auth tool failures", () => { it.effect("local propagates missing credential value as model-visible auth failure", () => Effect.gen(function* () { const integration = `auth_${randomBytes(4).toString("hex")}`; const connection = "main"; yield* run((client) => client.openapi.addSpec({ payload: { ...makeOpenApiHttpApiTestAddSpecPayload(MissingAuthSourceApi, { slug: integration, authenticationTemplate: [ { slug: AuthTemplateSlug.make(API_KEY_TEMPLATE), type: "apiKey" as const, headers: { Authorization: ["Bearer ", { type: "variable" as const, name: "token" }], }, }, ], }), baseUrl: "https://api.example.test", }, }), ); yield* Effect.promise(() => harness.addConnection({ integration, connection })); const execution = yield* run((client) => client.executions.execute({ payload: { code: [ `const result = await tools.${integration}.org.${connection}.default.ping({});`, "return result;", ].join("\n"), }, }), ); expectModelVisibleAuthFailure(execution); }), ); });