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
5 changes: 5 additions & 0 deletions .changeset/hosted-client-span-header-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Redact every span header attribute outside a safe allowlist on the hosted HTTP client. The tracer's default four-name blocklist let provider-specific credential headers reach the trace backend verbatim; the hosted client now inverts the model and masks everything except structurally safe negotiation, caching, and tracing headers.
8 changes: 8 additions & 0 deletions .changeset/mcp-client-span-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@executor-js/host-mcp": patch
"@executor-js/cloudflare": patch
---

**MCP execution spans now carry the client identity (`mcp.client.*`)**

The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior.
23 changes: 19 additions & 4 deletions .github/workflows/publish-selfhost-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,6 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
packages: write
env:
HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}

steps:
- name: Download image digests
uses: actions/download-artifact@v4
Expand Down Expand Up @@ -330,10 +327,28 @@ jobs:

docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"

# Mirrors each release to the pre-org-move namespace
# (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect.
# Deliberately a separate job: the canonical publish above never depends on
# it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows
# as one red job pointing at exactly what to fix.
mirror-legacy:
needs:
- metadata
- merge
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}

steps:
- name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
if: env.HAS_LEGACY_TOKEN != 'true'
run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."

- name: Set up Docker Buildx
if: env.HAS_LEGACY_TOKEN == 'true'
uses: docker/setup-buildx-action@v3

- name: Log in to legacy GHCR namespace
if: env.HAS_LEGACY_TOKEN == 'true'
uses: docker/login-action@v3
Expand All @@ -342,7 +357,7 @@ jobs:
username: rhyssullivan
password: ${{ secrets.GHCR_LEGACY_TOKEN }}

- name: Mirror self-host image to legacy GHCR namespace
- name: Mirror release tags to the legacy namespace
if: env.HAS_LEGACY_TOKEN == 'true'
shell: bash
env:
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const stubWorkOS = Layer.succeed(
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
Expand Down
4 changes: 3 additions & 1 deletion apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,9 @@ export const workosAccountProvider: Layer.Layer<
.updateOrganization(org.id, name)
.pipe(Effect.catchTag("WorkOSError", toAccountError));
yield* users
.use((s) => s.upsertOrganization({ id: updated.id, name: updated.name }))
.use("upsertOrganization", (s) =>
s.upsertOrganization({ id: updated.id, name: updated.name }),
)
.pipe(Effect.catchTag("UserStoreError", toAccountError));
return { name: updated.name };
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/api/protected-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const stubWorkOS = Layer.succeed(
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/api/protected-jwt-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const stubWorkOS = Layer.succeed(
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
Expand Down
10 changes: 9 additions & 1 deletion apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,15 @@ const McpApprovalErrors = [
/** Public auth endpoints — no authentication required */
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
.add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
// Sign-out is PUBLIC on purpose. The console posts it as a top-level form
// navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which
// means an error response is rendered as the page — and a browser whose
// session has already ended is exactly the browser most likely to click it
// (a second tab, a re-submit from history, an expired or revoked session).
// Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen
// instead of being signed out. There is nothing to authorize here anyway:
// the request can only end the session whose cookie it presents.
.add(HttpApiEndpoint.post("logout", "/auth/logout"))
.add(
HttpApiEndpoint.get("callback", "/auth/callback", {
query: AuthCallbackSearch,
Expand All @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: AuthErrors,
}),
)
.add(HttpApiEndpoint.post("logout", "/auth/logout"))
.add(
HttpApiEndpoint.get("organizations", "/auth/organizations", {
success: AuthOrganizationsResponse,
Expand Down
7 changes: 5 additions & 2 deletions apps/cloud/src/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"

type RawStore = ReturnType<typeof makeUserStore>;

// `op` names the store call so every span reads `user_store.<operation>`
// instead of one undifferentiated "user_store" bucket, and failures log which
// query actually failed.
const makeService = (store: RawStore) => ({
use: <A>(fn: (s: RawStore) => Promise<A>) =>
use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
withServiceLogging(
"user_store",
`user_store.${op}`,
() => new UserStoreError(),
tryPromiseService(() => fn(store)),
),
Expand Down
79 changes: 43 additions & 36 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n
HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS);

// ---------------------------------------------------------------------------
// Single non-protected API surface — public (login/callback) + session
// (me/logout/organizations/switch-organization). The session group has SessionAuth on it.
// Single non-protected API surface — public (login/callback/logout) + session
// (me/organizations/switch-organization). The session group has SessionAuth on it.
// ---------------------------------------------------------------------------

export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi);
Expand Down Expand Up @@ -202,7 +202,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
const result = yield* workos.authenticateWithCode(query.code);

// Mirror the account locally
yield* users.use((s) => s.ensureAccount(result.user.id));
yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id));

let sealedSession = result.sealedSession;

Expand Down Expand Up @@ -274,6 +274,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
);
}),
)
.handleRaw("logout", ({ request }) =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
// The session this browser presents, NOT one the middleware vouched
// for — signing out of a session that has already ended must still
// sign the browser out (see the group declaration in ./api.ts).
const sealedSession = request.cookies["wos-session"] ?? "";

// WorkOS's documented sign-out: send the browser through the WorkOS
// logout endpoint, which ends the AuthKit session upstream and then
// redirects to the registered sign-out URL. Without this hop, the
// hosted session survives and the next "Sign in" silently
// re-authenticates (issue #1445). Fail-open when the cookie won't
// unseal — there is then nothing to end upstream, and local sign-out
// must still complete, so fall back to "/".
const origin = env.VITE_PUBLIC_SITE_URL ?? "";
const logoutUrl = sealedSession
? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined)
: null;

const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 });

// Drop only what this browser actually presented. Both cookies are
// SameSite=Lax, so a cross-site form POST carries neither — it gets
// the bare redirect and cannot be used to sign anyone out.
if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response;

// The auth-hint travels with the session: leaving it behind would
// make the next page load optimistically paint the app shell for a
// signed-out browser.
return deleteResponseCookie(
deleteResponseCookie(response, "wos-session"),
AUTH_HINT_COOKIE,
);
}),
)
// CLI device-login discovery. The WorkOS device endpoints live on the
// WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
// the SAME base the SDK uses, so e2e points the CLI at the emulator with
Expand Down Expand Up @@ -319,35 +355,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
};
}),
)
.handleRaw("logout", () =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
const session = yield* SessionContext;

// WorkOS's documented sign-out: send the browser through the WorkOS
// logout endpoint, which ends the AuthKit session upstream and then
// redirects to the registered sign-out URL. Without this hop, the
// hosted session survives and the next "Sign in" silently
// re-authenticates (issue #1445). Fail-open when the cookie won't
// unseal: local sign-out must still complete, so fall back to "/".
const origin = env.VITE_PUBLIC_SITE_URL ?? "";
const logoutUrl = yield* workos.logoutUrl(
session.sealedSession,
origin ? `${origin}/` : undefined,
);

// The auth-hint travels with the session: leaving it behind would
// make the next page load optimistically paint the app shell for a
// signed-out browser.
return deleteResponseCookie(
deleteResponseCookie(
HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }),
"wos-session",
),
AUTH_HINT_COOKIE,
);
}),
)
.handle("organizations", () =>
Effect.gen(function* () {
const workos = yield* WorkOSClient;
Expand Down Expand Up @@ -420,7 +427,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
const org = yield* workos.createOrganization(name);
yield* workos.createMembership(org.id, session.accountId, "admin");
// `upsertOrganization` mints the slug at insert — no separate heal step.
const mirrored = yield* users.use((s) =>
const mirrored = yield* users.use("upsertOrganization", (s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
);

Expand Down Expand Up @@ -474,7 +481,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(

// The typed confirmation must match the org's current name — the same
// label the settings page shows. Trimmed on both sides.
const org = yield* users.use((s) => s.getOrganization(organizationId));
const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
if (!org || payload.confirmName.trim() !== org.name.trim()) {
return yield* new OrganizationDeletionForbidden();
}
Expand All @@ -492,7 +499,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
// everyone (unreachable) but its secrets/tenant rows linger orphaned —
// alert loudly so that window gets swept, then surface the failure.
yield* users
.use((s) => s.deleteOrganizationCascade(organizationId))
.use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId))
.pipe(
Effect.tapError((error) =>
Effect.logError(
Expand Down Expand Up @@ -593,7 +600,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
// Mirror the org locally so domain tables can FK against it; the
// upsert mints the slug at insert — no separate heal step.
const org = yield* workos.getOrganization(invitation.organizationId);
const mirrored = yield* users.use((s) =>
const mirrored = yield* users.use("upsertOrganization", (s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
);

Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/auth/org-api-key-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const stubWorkOS = Layer.succeed(
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/auth/org-selector-auth.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const stubWorkOS = Layer.succeed(
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
Expand Down
8 changes: 5 additions & 3 deletions apps/cloud/src/auth/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos";
export const resolveOrganization = (organizationId: string) =>
Effect.gen(function* () {
const users = yield* UserStoreService;
const existing = yield* users.use((s) => s.getOrganization(organizationId));
const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
if (existing) return existing;

const workos = yield* WorkOSClient;
const fresh = yield* workos.getOrganization(organizationId);
return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name }));
return yield* users.use("upsertOrganization", (s) =>
s.upsertOrganization({ id: fresh.id, name: fresh.name }),
);
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -125,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string)
return yield* authorizeOrganization(userId, selector);
}
const users = yield* UserStoreService;
const org = yield* users.use((s) => s.getOrganizationBySlug(selector));
const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector));
if (!org) return null;
return yield* authorizeOrganization(userId, org.id);
});
2 changes: 1 addition & 1 deletion apps/cloud/src/auth/ssr-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ const organizationDisplay = async (
): Promise<{ name: string; slug: string }> => {
const exit = await getRuntime().runPromiseExit(
Effect.flatMap(UserStoreService.asEffect(), (users) =>
users.use((store) => store.getOrganization(organizationId)),
users.use("getOrganization", (store) => store.getOrganization(organizationId)),
).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
);
return Exit.isSuccess(exit)
Expand Down
Loading
Loading