Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/credential-check-advice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@executor-js/ui": patch
---

When an app's check rejects or can't verify credentials in the connect form, the
form now says the check may be wrong, that the credential can still be added,
and offers a fix prompt that asks the user's agent to correct the app's check.
9 changes: 9 additions & 0 deletions .changeset/mcp-structured-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"apps": patch
"@executor-js/app-templates": patch
---

Remote MCP results drop a text block that only repeats `structuredContent` as
JSON. Servers send that copy for clients that cannot read structured results;
kept, it doubled the result at every nested MCP hop, such as Executor behind
Executor. Read `structuredContent`; other content is unchanged.
361 changes: 361 additions & 0 deletions DEVELOPMENT.md

Large diffs are not rendered by default.

414 changes: 86 additions & 328 deletions README.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions apps/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# executor

## 2.0.0-beta.38

Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image.

## 2.0.0-beta.37

Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image.

## 2.0.0-beta.36

Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "executor",
"version": "2.0.0-beta.36",
"version": "2.0.0-beta.38",
"private": true,
"description": "Run Executor locally and connect your agents to your apps.",
"homepage": "https://executor.sh",
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ needs. That is the point: the whole result set never has to pass through the
model's context.

A remote MCP tool returns its whole MCP result: `content`, `structuredContent`
and `isError`. `isError: true` is the tool saying it failed. The call returns
and `isError`. A server that returns `structuredContent` usually repeats it as
JSON text in `content`; Executor drops that copy, so read `structuredContent`.
Other content stays as the server sent it. `isError: true` is the tool saying it failed. The call returns
that result instead of throwing, so programs that read `isError` keep working,
and the result's `toolCalls` lists the call with outcome `failure`. The server
may still have made changes. `call` lists it the same way. An outcome of
Expand Down
5 changes: 3 additions & 2 deletions apps/hosted/cloud/alchemy.posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ export default Alchemy.Stack(
definition: onboardingExperimentDefinition,
status: "running",
}).pipe(retain());
// Independent of the order experiment; also running, so the same deploy launches it.
// Independent of the order experiment. Ended in PostHog on 2026-10-30; an ended experiment
// cannot run again.
const onboardingSkip = yield* PostHogExperiment("OnboardingSkipExperiment", {
projectId: project.id,
definition: onboardingSkipExperimentDefinition,
status: "running",
status: "stopped",
}).pipe(retain());
return {
onboardingExperimentId: onboarding.id,
Expand Down
2 changes: 1 addition & 1 deletion apps/hosted/cloud/scripts/edge-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const forwarded: ReadonlyArray<readonly [method: string, target: string]> = [
["GET", "/api/auth/callback/google?code=c&state=s"],
["GET", "/api/auth/callback/github"],
["GET", `/api/oauth/callback?code=c&state=${accountOAuthStatePrefix}abc`],
["GET", "/oauth/client-metadata.json"],
["GET", "/git/acme/site.git/info/refs?service=git-upload-pack"],
["POST", "/git/acme/site.git/git-receive-pack"],
["GET", "/"],
Expand Down Expand Up @@ -78,7 +79,6 @@ const kept: ReadonlyArray<readonly [method: string, target: string]> = [
["GET", "/api/auth/login"],
["GET", "/api/oauth/callback?code=c&state=abc"],
["GET", "/api/oauth/callback?code=c"],
["GET", "/oauth/client-metadata.json"],
["GET", "/oauth/client-id-metadata.json"],
["GET", "/git"],
["GET", "/gitlab/repo"],
Expand Down
13 changes: 10 additions & 3 deletions apps/hosted/cloud/src/app-ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { requestServices } from "@executor-js/hosted-server";
import { recordUnclassifiedRefusals, requestServices } from "@executor-js/hosted-server";
import { previewLifetime } from "./infrastructure/test-stage-expiry.ts";
/** Private app-origin entry point. Dashboard assets and management APIs are never mounted here. */
import { hostedAppUi, appAddresses } from "@executor-js/hosted-server/app-ui";
Expand All @@ -10,7 +10,12 @@ import { Config, Effect, Layer, Option } from "effect";
import { HttpApiBuilder } from "effect/http-api";
import { HttpRouter, HttpServer, HttpServerError, HttpServerResponse } from "effect/http";
import { cloudAppUiBase, cloudAppUiPort, cloudAppUiRoute } from "./contracts/app-ui.ts";
import { recordRequestRejections, requestTiming } from "@executor-js/telemetry/http";
import {
toRequestHttpEffect,
recordRequestRejections,
requestTiming,
} from "@executor-js/telemetry/http";
import { recordClientNetwork } from "./implementation/client-network.ts";
import { cloudSentry } from "./implementation/error-reporting.ts";
import { cloudAnalytics } from "./implementation/product-analytics.ts";
import { postHogBindings } from "./infrastructure/posthog.ts";
Expand Down Expand Up @@ -99,7 +104,7 @@ export default class AppPages extends Cloudflare.Worker<AppPages>()(
).pipe(Layer.provide(appUi.originAccess.layer));
const handle = yield* routes.pipe(
Layer.provide(HttpServer.layerServices),
HttpRouter.toHttpEffect,
toRequestHttpEffect,
Effect.provideService(Layer.CurrentMemoMap, yield* Layer.makeMemoMap),
);
const lifetime = yield* previewLifetime;
Expand All @@ -124,6 +129,8 @@ export default class AppPages extends Cloudflare.Worker<AppPages>()(
: Effect.succeed(HttpServerResponse.empty({ status: 500, headers: appPrivateHeaders })),
),
requestTiming,
recordClientNetwork,
recordUnclassifiedRefusals,
lifetime.http,
),
};
Expand Down
11 changes: 6 additions & 5 deletions apps/hosted/cloud/src/contracts/edge-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"/.well-known/oauth-authorization-server/api/auth",
"/api/auth/callback/<provider>",
"/api/oauth/callback, when state starts with x2.",
"/oauth/client-metadata.json",
"/git/*",
"/ (without a v1 session)",
"/home",
Expand Down Expand Up @@ -56,6 +57,11 @@
"target": "/api/oauth/callback?code=c&state=x2.abc",
"forwards": true
},
{
"method": "GET",
"target": "/oauth/client-metadata.json",
"forwards": true
},
{
"method": "GET",
"target": "/git/acme/site.git/info/refs?service=git-upload-pack",
Expand Down Expand Up @@ -241,11 +247,6 @@
"target": "/api/oauth/callback?code=c",
"forwards": false
},
{
"method": "GET",
"target": "/oauth/client-metadata.json",
"forwards": false
},
{
"method": "GET",
"target": "/oauth/client-id-metadata.json",
Expand Down
4 changes: 4 additions & 0 deletions apps/hosted/cloud/src/contracts/edge-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
export const accountOAuthStatePrefix = "x2.";

import { clientMetadataDocumentPath } from "@executor-js/hosted-server/oauth-client-metadata";
import { Option } from "effect";
import {
agentSkills,
Expand Down Expand Up @@ -89,6 +90,9 @@ const routedEdgeForwards: ReadonlyArray<EdgeForward> = [
url.pathname === "/api/oauth/callback" &&
(url.searchParams.get("state") ?? "").startsWith(accountOAuthStatePrefix),
},
// The OAuth client metadata document connected-account sign-ins name as their client ID, on the
// edge's origin like their callback. v1's own document is `/oauth/client-id-metadata.json`.
exact(clientMetadataDocumentPath),
// Git smart HTTP for app repositories.
{ rule: "/git/*", matches: (url) => url.pathname.startsWith("/git/") },
// The site: the homepage for visitors without a v1 session, its pages, their files and the
Expand Down
179 changes: 179 additions & 0 deletions apps/hosted/cloud/src/implementation/auth-audit-vocabulary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* The closed vocabularies the Better Auth security audit (`auth-audit.ts`) reads: which endpoints
* it audits and how, the failure codes a record may name, the decoders for the request body
* fields and stored answers it reads, and how an endpoint's answer decodes to its outcome.
* Anything outside them is never recorded as given.
*/
import { PASSKEY_ERROR_CODES } from "@better-auth/passkey";
import { BASE_ERROR_CODES } from "better-auth";
import { APIError } from "better-auth/api";
import { Option, Schema } from "effect";
import { auditFailureCodes } from "@executor-js/hosted-server";
import { oauthErrorCodes } from "./auth-observability.ts";

/**
* How each sign-in endpoint signs in. A session it creates is a success. Other endpoints also set
* a new session, such as `/get-session` when it extends one, so only these count as sign-ins.
*/
export const signIns: Readonly<Record<string, string>> = {
"/sign-in/email-otp": "email_otp",
"/sign-in/social": "social",
"/callback/:id": "social",
"/oauth-proxy-callback": "social",
"/passkey/verify-authentication": "passkey",
"/sign-in/sso": "sso",
"/sso/callback": "sso",
"/sso/callback/:providerId": "sso",
"/sso/saml2/sp/acs/:providerId": "sso",
"/v1-handoff/session": "v1_handoff",
};
/** Endpoints that check a one-time code outside sign-in, where a wrong code is the event. */
export const otpChecks = new Set([
"/email-otp/verify-email",
"/email-otp/check-verification-otp",
"/email-otp/request-email-change",
"/email-otp/change-email",
"/email-otp/reset-password",
]);
/** Endpoints that end sessions. */
export const revocations = new Set([
"/sign-out",
"/revoke-session",
"/revoke-sessions",
"/revoke-other-sessions",
]);
/** The other audited endpoints: API keys, members, invitations, OAuth clients, impersonation. */
const actions = new Set([
"/api-key/create",
"/api-key/delete",
"/organization/update-member-role",
"/organization/remove-member",
"/organization/invite-member",
"/organization/accept-invitation",
"/organization/cancel-invitation",
"/oauth2/register",
"/oauth2/consent",
"/admin/impersonate-user",
"/admin/stop-impersonating",
]);
export const audited = (path: string | undefined) =>
path !== undefined &&
(path in signIns || otpChecks.has(path) || revocations.has(path) || actions.has(path));

/**
* The failure codes these endpoints answer with: Better Auth's own and its passkey plugin's, the
* codes of the email-code, organization and API key plugins they raise, the OAuth errors a sign-in
* returns with, and an HTTP status where the answer carries no code. Anything else is `other`.
*/
export const failureOf = auditFailureCodes([
...Object.keys(BASE_ERROR_CODES),
...Object.keys(PASSKEY_ERROR_CODES),
// Email codes.
"OTP_EXPIRED",
"INVALID_OTP",
"TOO_MANY_ATTEMPTS",
// Organization members and invitations.
"MEMBER_NOT_FOUND",
"NO_ACTIVE_ORGANIZATION",
"ORGANIZATION_NOT_FOUND",
"ORGANIZATION_MEMBERSHIP_LIMIT_REACHED",
"ROLE_NOT_FOUND",
"USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION",
"USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION",
"INVITATION_NOT_FOUND",
"INVITATION_LIMIT_REACHED",
"INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION",
"EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION",
"YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER",
"YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER",
"YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION",
"YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION",
"YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE",
"YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION",
"YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER",
// API keys.
"KEY_NOT_FOUND",
"UNAUTHORIZED_SESSION",
"INVALID_NAME_LENGTH",
"NAME_REQUIRED",
// Impersonation.
"YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS",
"YOU_CANNOT_IMPERSONATE_ADMINS",
...oauthErrorCodes,
...Array.from({ length: 200 }, (_, offset) => String(400 + offset)),
]);

const Id = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256));
/**
* The request body fields the endpoints read, each decoded on its own. Better Auth ignores a field
* an endpoint does not take, so one such field's value must not hide another's.
*/
export const decodeKeyId = Schema.decodeUnknownOption(Schema.Struct({ keyId: Id }));
export const decodeRole = Schema.decodeUnknownOption(
Schema.Struct({ role: Schema.Literals(["owner", "admin", "member"]) }),
);
export const decodeAccept = Schema.decodeUnknownOption(Schema.Struct({ accept: Schema.Boolean }));
export const decodeOAuthQuery = Schema.decodeUnknownOption(
Schema.Struct({ oauth_query: Schema.String }),
);
/** The stored records the endpoints answer with. */
const Returned = Schema.Struct({
id: Schema.optionalKey(Id),
client_id: Schema.optionalKey(Id),
organizationId: Schema.optionalKey(Id),
metadata: Schema.optionalKey(
Schema.NullOr(Schema.Struct({ organization: Schema.optionalKey(Id) })),
),
member: Schema.optionalKey(Schema.Struct({ id: Id, organizationId: Id })),
invitation: Schema.optionalKey(Schema.Struct({ id: Id, organizationId: Id })),
});
const ErrorBody = Schema.Struct({ code: Schema.optionalKey(Schema.String) });
export const decodeReturned = Schema.decodeUnknownOption(Returned);
const decodeError = Schema.decodeUnknownOption(ErrorBody);
export const decodeId = Schema.decodeUnknownOption(Id);

/** A signed-in session, as a hook or the endpoint's authentication loaded it. */
export interface Session {
readonly user: { readonly id: string };
readonly session: { readonly id: string; readonly impersonatedBy?: string | null | undefined };
}
/** The structural part of Better Auth's hook context this module reads. */
export interface HookContext {
readonly path?: string | undefined;
readonly body?: unknown;
readonly context: {
readonly returned?: unknown;
readonly newSession?:
| {
readonly user: { readonly id: string };
readonly session: { readonly impersonatedBy?: string | null | undefined };
}
| null
| undefined;
readonly session?: Session | null | undefined;
readonly responseHeaders?: Headers | undefined;
};
}

/**
* The endpoint's outcome: failure with Better Auth's error code, or the `error` a redirect carries
* back to the browser; success otherwise. A redirect is an `APIError` whatever it means.
*/
export const outcome = (ctx: HookContext) => {
const returned = ctx.context.returned;
if (returned instanceof APIError && returned.statusCode >= 400)
return failureOf(
Option.getOrUndefined(decodeError(returned.body))?.code ?? String(returned.statusCode),
);
if (returned instanceof Response && returned.status >= 400)
return failureOf(String(returned.status));
const location =
(returned instanceof APIError ? new Headers(returned.headers).get("location") : null) ??
ctx.context.responseHeaders?.get("location") ??
null;
const error =
location === null
? null
: URL.parse(location, "https://auth.invalid")?.searchParams.get("error");
return error === null || error === undefined ? undefined : failureOf(error);
};
Loading
Loading