forked from UsefulSoftwareCo/executor
-
Notifications
You must be signed in to change notification settings - Fork 0
CI probe: bound credential provider call (fork-internal, not for upstream) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
GeiserX
wants to merge
3
commits into
main
Choose a base branch
from
fix/bound-credential-provider-call
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+295
−2
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // --------------------------------------------------------------------------- | ||
| // A credential provider that stops answering must fail the resolution, not hang it. | ||
| // | ||
| // A provider is frequently REMOTE — an HTTP secret store, or under sealed custody a | ||
| // vault that may live in another enclave — so "stopped answering" is one of its | ||
| // ordinary failure modes. Unbounded, a store that goes away does not fail a tool | ||
| // invocation, it hangs it, and nothing in the resulting silence names the provider. | ||
| // | ||
| // Executor already bounds its other remote calls this way (OAuth discovery, the MCP | ||
| // plugin's probes); credential resolution was the one that did not. | ||
| // | ||
| // Time is virtual here: the bound is deliberately generous, and a test that waited | ||
| // it out in real time would be a thirty-second test. TestClock is advanced past it | ||
| // instead — which is also why this uses `it.effect` rather than `it.live`. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| import { describe, expect, it } from "@effect/vitest"; | ||
| import { Duration, Effect, Exit, Fiber } from "effect"; | ||
| import { TestClock } from "effect/testing"; | ||
|
|
||
| import { createExecutor } from "./executor"; | ||
| import { | ||
| AuthTemplateSlug, | ||
| ConnectionName, | ||
| IntegrationSlug, | ||
| ProviderItemId, | ||
| ProviderKey, | ||
| } from "./ids"; | ||
| import { definePlugin } from "./plugin"; | ||
| import type { CredentialProvider } from "./provider"; | ||
| import { makeTestConfig } from "./test-config"; | ||
|
|
||
| const STORE = ProviderKey.make("remote-store"); | ||
| const INTEG = IntegrationSlug.make("acme"); | ||
| const CONN = ConnectionName.make("main"); | ||
|
|
||
| const providerWith = (get: CredentialProvider["get"]): CredentialProvider => ({ | ||
| key: STORE, | ||
| writable: true, | ||
| get, | ||
| set: () => Effect.void, | ||
| }); | ||
|
|
||
| const plugin = (provider: CredentialProvider) => | ||
| definePlugin(() => ({ | ||
| id: "acme" as const, | ||
| credentialProviders: [provider], | ||
| storage: () => ({}), | ||
| extension: (ctx) => ({ | ||
| seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), | ||
| read: () => ctx.connections.resolveValue({ owner: "org", integration: INTEG, name: CONN }), | ||
| }), | ||
| }))(); | ||
|
|
||
| const executorWithConnection = (provider: CredentialProvider) => | ||
| Effect.gen(function* () { | ||
| const executor = yield* createExecutor( | ||
| makeTestConfig({ plugins: [plugin(provider)] as const }), | ||
| ); | ||
| yield* executor.acme.seed(); | ||
| yield* executor.connections.create({ | ||
| owner: "org", | ||
| name: CONN, | ||
| integration: INTEG, | ||
| template: AuthTemplateSlug.make("api_key"), | ||
| from: { provider: STORE, id: ProviderItemId.make("item-1") }, | ||
| }); | ||
| return executor; | ||
| }); | ||
|
|
||
| describe("a credential provider that stops answering", () => { | ||
| it.effect("fails the resolution instead of hanging it", () => | ||
| Effect.gen(function* () { | ||
| const executor = yield* executorWithConnection(providerWith(() => Effect.never)); | ||
|
|
||
| const fiber = yield* Effect.forkChild(Effect.exit(executor.acme.read())); | ||
| yield* TestClock.adjust(Duration.minutes(5)); | ||
| const exit = yield* Fiber.join(fiber); | ||
|
|
||
| expect(Exit.isFailure(exit)).toBe(true); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("names the provider and the operation, not just a failure", () => | ||
| Effect.gen(function* () { | ||
| // A bare timeout would leave an operator looking at whatever the caller was | ||
| // doing rather than at the store that stopped answering. | ||
| const executor = yield* executorWithConnection(providerWith(() => Effect.never)); | ||
|
|
||
| const fiber = yield* Effect.forkChild(Effect.exit(executor.acme.read())); | ||
| yield* TestClock.adjust(Duration.minutes(5)); | ||
| const exit = yield* Fiber.join(fiber); | ||
|
|
||
| expect(String(exit)).toContain("remote-store"); | ||
| expect(String(exit)).toContain("did not answer"); | ||
| // The operation, too — without this the test passes its own name by accident: | ||
| // the operation could drop out of the message entirely and nothing would notice. | ||
| expect(String(exit)).toContain("get"); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("an object-literal provider stores a pasted value — the control", () => | ||
| Effect.gen(function* () { | ||
| // The control for the class case below: identical in every respect except that the | ||
| // provider is an object literal. Without it, a red class test could mean anything. | ||
| const items = new Map<string, string>(); | ||
| const lit: CredentialProvider = { | ||
| key: STORE, | ||
| writable: true, | ||
| get: (id) => Effect.sync(() => items.get(String(id)) ?? null), | ||
| set: (id, value) => Effect.sync(() => void items.set(String(id), value)), | ||
| }; | ||
| const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const })); | ||
| yield* executor.acme.seed(); | ||
| yield* executor.connections.create({ | ||
| owner: "org", | ||
| name: CONN, | ||
| integration: INTEG, | ||
| template: AuthTemplateSlug.make("api_key"), | ||
| value: "tok", | ||
| }); | ||
| expect(yield* executor.acme.read()).toBe("tok"); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("wraps a CLASS-based provider without breaking its methods", () => | ||
| Effect.gen(function* () { | ||
| // The wrapper must not change HOW a provider's own methods are called. `get` was | ||
| // invoked with its receiver (`provider.get(id)`) but the optional methods were | ||
| // destructured and called bare, which silently drops `this`. Every in-tree provider | ||
| // is an object literal and cannot notice; a provider written as a class — exactly | ||
| // what "wrap any provider" invites — throws TypeError on the first optional call. | ||
| class ClassProvider { | ||
| readonly key = STORE; | ||
| readonly writable = true; | ||
| private readonly items = new Map<string, string>(); | ||
| get(id: ProviderItemId) { | ||
| return Effect.sync(() => this.items.get(String(id)) ?? null); | ||
| } | ||
| set(id: ProviderItemId, value: string) { | ||
| // `this` is the whole point: bare invocation makes this line throw. | ||
| return Effect.sync(() => void this.items.set(String(id), value)); | ||
| } | ||
| } | ||
|
|
||
| const executor = yield* createExecutor( | ||
| makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }), | ||
| ); | ||
| yield* executor.acme.seed(); | ||
| yield* executor.connections.create({ | ||
| owner: "org", | ||
| name: CONN, | ||
| integration: INTEG, | ||
| template: AuthTemplateSlug.make("api_key"), | ||
| value: "tok", | ||
| }); | ||
|
|
||
| expect(yield* executor.acme.read()).toBe("tok"); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("keeps a capability the provider defines on its PROTOTYPE", () => | ||
| Effect.gen(function* () { | ||
| // The wrapper must not change the provider's SHAPE either. A spread copies only own | ||
| // ENUMERABLE properties, so anything on a class's prototype — every method, and any | ||
| // accessor like the `writable` below — is dropped silently. Nothing raises; the wrapper | ||
| // simply appears not to have it, and the caller takes a path the provider meant to own. | ||
| // Here that means `defaultWritableProvider` no longer sees a writable store, so creating | ||
| // a connection from a pasted value fails with no provider at all. | ||
| class PrototypeProvider { | ||
| readonly key = STORE; | ||
| private readonly items = new Map<string, string>(); | ||
| // On the PROTOTYPE, not the instance — this is the property a spread loses. | ||
| get writable() { | ||
| return true; | ||
| } | ||
| get(id: ProviderItemId) { | ||
| return Effect.sync(() => this.items.get(String(id)) ?? null); | ||
| } | ||
| set(id: ProviderItemId, value: string) { | ||
| return Effect.sync(() => void this.items.set(String(id), value)); | ||
| } | ||
| } | ||
|
|
||
| const executor = yield* createExecutor( | ||
| makeTestConfig({ | ||
| plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, | ||
| }), | ||
| ); | ||
| yield* executor.acme.seed(); | ||
| yield* executor.connections.create({ | ||
| owner: "org", | ||
| name: CONN, | ||
| integration: INTEG, | ||
| template: AuthTemplateSlug.make("api_key"), | ||
| value: "tok", | ||
| }); | ||
|
|
||
| expect(yield* executor.acme.read()).toBe("tok"); | ||
| }), | ||
| ); | ||
|
|
||
| it.effect("still resolves normally when the provider answers", () => | ||
| Effect.gen(function* () { | ||
| // The control. A bound that refused everything would satisfy both assertions | ||
| // above while breaking every working deployment. | ||
| const executor = yield* executorWithConnection(providerWith(() => Effect.succeed("tok"))); | ||
|
|
||
| expect(yield* executor.acme.read()).toBe("tok"); | ||
| }), | ||
| ); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Preserve the provider receiver for inherited accessors.
Object.create(provider)preserves the accessor definition but invokes it withboundedasthis. Ifwritablereads a private field, such asreturn this.#writable,defaultWritableProvider()throws becauseboundeddoes not have that private-field brand. Forwardkeyandwritableto the original provider, or use a proxy that reads non-wrapped properties withprovideras the receiver.Add a regression case where
PrototypeProvider.writablereturns a private#writablefield.packages/core/sdk/src/executor.ts#L1723-L1753: forward inherited accessor reads toprovider.packages/core/sdk/src/provider-call-timeout.test.ts#L170-L199: makewritableread a private field.📍 Affects 2 files
packages/core/sdk/src/executor.ts#L1723-L1753(this comment)packages/core/sdk/src/provider-call-timeout.test.ts#L170-L199🤖 Prompt for AI Agents