Skip to content
Closed
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
20 changes: 20 additions & 0 deletions .changeset/approval-atomicity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@executor-js/sdk": patch
---

fix: make pending-approval consumption atomic

`PendingApprovalStore.consume` previously read the record and deleted it as
two separate operations. Two concurrent resumes (a double-click, a client
retry, or two hosts racing the same approval) could both read the record
before either deleted it, and both would execute the approved tool call —
duplicated side effects from a single approval.

Consumption now goes through a new `BlobStore.compareAndDelete` primitive
with a single-winner guarantee: exactly one concurrent consumer observes the
record as present-and-removed; everyone else observes it as absent. The
in-memory store implements it as a synchronous Map operation (atomic in JS's
single-threaded model); the FumaDB-backed store implements it as
get+delete inside the serializing transaction the driver already provides
(libSQL/Postgres BEGIN/COMMIT). The approval's expiry and corrupt-record
semantics are unchanged.
21 changes: 21 additions & 0 deletions .changeset/egress-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@executor-js/sdk": patch
"@executor-js/plugin-openapi": patch
---

fix: block SSRF targets when fetching integration specs by URL

Adding an OpenAPI (or other URL-based) integration fetched the spec URL
server-side with no egress filtering. A crafted URL pointing at cloud
metadata (169.254.169.254), loopback, RFC1918, or link-local addresses let
the fetch feature reach internal state on hosted deployments.

A shared egress guard (`assertFetchable`) now validates every spec-fetch
target before connecting: it normalizes DNS-encoding tricks (decimal/octal/
hex integer IPv4, trailing dots), resolves hostnames, and fails closed if
any resolved address is loopback, RFC1918, link-local, carrier-grade NAT,
IPv6 link-local/ULA, or IPv4-mapped private. The resolved address is pinned
for the connect (no second resolution, so DNS rebinding cannot swap in a
private target), and the original host is preserved in the Host header.
Rejections are coarse ("blocked by egress policy") and never echo internal
addresses.
21 changes: 21 additions & 0 deletions .changeset/execution-tombstones.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@executor-js/sdk": patch
"@executor-js/api": patch
"@executor-js/local-app": patch
---

fix: surface executions interrupted by a daemon restart instead of losing them silently

A paused execution lives as an in-memory fiber inside the running engine. When
the local service restarts (login, crash, upgrade), every fiber is gone and a
later `executor resume` read as "approval expired" — silently discarding work
the agent believed was still pending.

Executions now write a lightweight durable tombstone (id + status +
timestamp, no arguments, no results, no secrets) at pause time. On boot the
service marks every non-terminal tombstone `interrupted`; resuming an
interrupted execution returns an explicit `InterruptedExecutionError` telling
the agent to re-trigger the action, which is safe because nothing ran.

Also adds the `@executor-js/sdk` execution-record store used by hosts that
need the same guarantee (cloud, self-host).
21 changes: 21 additions & 0 deletions .changeset/otc-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@executor-js/local-app": patch
"@executor-js/cli": patch
"@executor-js/react": patch
---

fix: replace the token-in-URL web bootstrap with a one-time-code exchange

Opening the web UI previously put the daemon bearer token in the URL
(`?_token=<token>`) and the SPA persisted it to localStorage — both are
leak-prone surfaces (browser history, logs, screen recordings, and
localStorage is readable by any script on the origin).

`executor web` / `executor open` now mint a one-time code (bearer-gated,
single-use, 60-second TTL, 128-bit entropy, bound to the running daemon
instance) and open `/?_otc=<code>`. On first load the SPA exchanges the
code for the bearer, applies it to the in-memory connection, and strips the
query. The server also sets an HttpOnly SameSite=strict cookie as transport
hardening. Nothing is written to localStorage by the bootstrap path; the
legacy `?_token=` query is still accepted for compatibility with older
daemons but is no longer persisted.
5 changes: 5 additions & 0 deletions .changeset/policy-transactional-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/sdk": patch
---

Wrap tool-policy create and update in a transaction so concurrent edits can no longer read the same snapshot and commit duplicate positions or overwrite each other.
10 changes: 10 additions & 0 deletions .changeset/reduced-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@executor-js/react": patch
---

fix: honor prefers-reduced-motion in the shared stylesheet

Adds a `prefers-reduced-motion: reduce` block to the global stylesheet that
caps transition/animation durations to 0.01ms and disables smooth scrolling,
so motion-sensitive users get a stable UI. The loading spinner renders
statically under reduced motion (its meaning is preserved via `role="status"`).
22 changes: 22 additions & 0 deletions .changeset/secure-secrets-and-stdio-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@executor-js/local-app": patch
"@executor-js/plugin-keychain": patch
"@executor-js/api": patch
---

fix: prefer the OS keychain for secrets, disable stdio MCP by default, and log approval-record failures

- The local app now registers the keychain credential provider before the file
store, so minted OAuth tokens land in the OS keychain on platforms where it
is durable (macOS/Windows). On headless/Linux hosts where the keychain probe
fails, the file store remains the effective default — behavior is unchanged
there, and a new `describeKeychainAvailability()` helper encodes the platform
truth that drives the ordering.
- `dangerouslyAllowStdioMCP` now defaults to `false` in the shipped local
config. Stdio MCP servers spawn local subprocesses; enabling the flag
explicitly is required for trusted local contexts, and the MCP plugin
already rejects stdio connections with a clear error when disabled.
- Approval-record persistence failures (the best-effort durable record behind
artifact approvals) are no longer swallowed silently: the failure cause is
captured through the host's error-capture channel so operators can see when
the restart-recovery fallback degrades. Execution behavior is unchanged.
17 changes: 17 additions & 0 deletions .changeset/tidy-login-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@executor-js/cloud": patch
---

fix: make login CSRF state mandatory in the WorkOS callback

The callback previously skipped its CSRF check whenever the redirect carried
no `state` value ("some WorkOS-initiated redirects don't include one"). That
bypass let an attacker complete their own OAuth round-trip and redirect a
victim's browser through the callback with the attacker's `code` and no
`state`, silently signing the victim into the attacker's account (login CSRF).

The check is now unconditional: a callback without a state matching the
`wos-login-state` cookie set on `/login` is rejected with 400. This is a
breaking change for any client relying on the undocumented no-state entry
path; server-initiated flows that cannot carry state must be redesigned with
a signed nonce instead of re-adding the bypass.
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@
},
],
"ignorePatterns": [
".agents/",
".astro/",
".reference/",
".references/",
Expand Down
33 changes: 31 additions & 2 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,10 @@ const runForegroundSession = (input: {

try {
console.log(`Executor is ready.`);
console.log(`Open: ${baseUrl}/?_token=${server.authToken}`);
const otcCode = server.otcStore?.issue() ?? null;
console.log(
`Open: ${otcCode ? `${baseUrl}/?_otc=${otcCode}` : `${baseUrl}/?_token=${server.authToken}`}`,
);
console.log(`Web: ${baseUrl}`);
console.log(`MCP: ${baseUrl}/mcp`);
console.log(`OpenAPI: ${baseUrl}/api/docs`);
Expand Down Expand Up @@ -3269,11 +3272,37 @@ const openRunningLocalWebApp = (): Effect.Effect<
}
const { origin, auth } = manifest.connection;
const token = auth?.kind === "bearer" ? auth.token : undefined;
const url = token ? `${origin}/?_token=${token}` : origin;
if (!token) {
console.log(`Opening ${origin}`);
yield* openInBrowser(origin);
return;
}
// Mint a one-time bootstrap code instead of putting the bearer in the
// URL. The browser exchanges it for the bearer on first load (HttpOnly
// cookie + in-memory connection), and the query is stripped.
const otc = yield* mintOtcForDaemon(origin, token);
const url = otc ? `${origin}/?_otc=${otc}` : `${origin}/?_token=${token}`;
console.log(`Opening ${url}`);
yield* openInBrowser(url);
});

/** Mint a one-time bootstrap code from the running daemon (bearer-gated).
* Falls back to null on any failure — the caller then falls back to the
* legacy `?_token=` URL rather than failing the open. */
const mintOtcForDaemon = (origin: string, token: string): Effect.Effect<string | null> =>
Effect.tryPromise({
try: async () => {
const res = await fetch(`${origin}/api/auth/otc`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const body = (await res.json()) as { readonly code?: unknown };
return typeof body.code === "string" && body.code.length > 0 ? body.code : null;
},
catch: () => null,
}).pipe(Effect.catch(() => Effect.succeed(null)));

/**
* `executor open` — the friendly way back in. Reads the running local server's
* manifest and opens the browser straight to its `?_token=` URL, so the user
Expand Down
22 changes: 11 additions & 11 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,17 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
const workos = yield* WorkOSClient;
const users = yield* UserStoreService;
const cookieState = request.cookies[STATE_COOKIE] ?? null;
// CSRF check is only enforced when the redirect carries a state
// value — some WorkOS-initiated redirects don't include one.
// When state is present, it MUST match the cookie we set on
// /login.
if (query.state !== undefined) {
if (!cookieState || !timingSafeEqual(cookieState, query.state)) {
return deleteResponseCookie(
HttpServerResponse.text("Invalid login state", { status: 400 }),
STATE_COOKIE,
);
}
// CSRF is unconditional: every callback must carry a state that
// matches the cookie set on /login. There is no legitimate
// no-state entry path — omitting state previously allowed an
// attacker to complete their own OAuth round-trip and redirect a
// victim's browser through this callback, signing the victim into
// the attacker's account (login CSRF).
if (!cookieState || !timingSafeEqual(cookieState, query.state ?? "")) {
return deleteResponseCookie(
HttpServerResponse.text("Invalid login state", { status: 400 }),
STATE_COOKIE,
);
}

const result = yield* workos.authenticateWithCode(query.code);
Expand Down
149 changes: 149 additions & 0 deletions apps/cloud/src/auth/workos-callback-state.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// ---------------------------------------------------------------------------
// Focused tests — the WorkOS login callback's CSRF gate.
//
// The callback's CSRF check must be unconditional: no state ⇒ 400 before any
// WorkOS call; a replayed (already consumed) state ⇒ 400; a fresh state
// matching the cookie ⇒ 302 + session.
//
// Test seams follow repo conventions: @effect/vitest, Layer.succeed stubs
// (see org-selector-auth.node.test.ts), and HttpRouter.toWebHandler for the
// HTTP surface (see api.request-scope.node.test.ts).
// ---------------------------------------------------------------------------

import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { HttpRouter, HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpApi } from "effect/unstable/httpapi";

import { CloudAuthPublicHandlers } from "./handlers";
import { CloudAuthPublicApi } from "./api";
import { UserStoreService } from "./context";
import { WorkOSClient, type WorkOSClientService } from "./workos";
import { encodeLoginState } from "./login-state";

// The route under test serves under the `/api` prefix in the composed app;
// toWebHandler mounts the raw group, so paths here are relative to the group.
const SESSION_COOKIE = "wos-session";
const STATE_COOKIE = "wos-login-state";

const STUB_USER_ID = "user_test";
const STUB_SESSION = "sealed-session-stub";
const STUB_ORG_ID = "org_test";

const stubWorkOS = Layer.succeed(
WorkOSClient,
new Proxy({} as WorkOSClientService, {
get: (_t, prop) => {
if (prop === "authenticateWithCode") {
return () =>
Effect.succeed({
user: { id: STUB_USER_ID, email: "u@test" },
organizationId: STUB_ORG_ID,
sealedSession: STUB_SESSION,
});
}
if (prop === "listUserMemberships") {
return () => Effect.succeed({ data: [] });
}
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
},
}),
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt: new Date() }),
getAccount: async (id: string) => ({ id, createdAt: new Date() }),
upsertOrganization: async (org: { id: string; name: string }) => ({
...org,
slug: org.id,
createdAt: new Date(),
}),
getOrganization: async (id: string) => ({
id,
name: "Org " + id,
slug: id,
createdAt: new Date(),
}),
getOrganizationBySlug: async (slug: string) => ({
id: slug,
name: slug,
slug,
createdAt: new Date(),
}),
deleteOrganizationCascade: async () => {},
}),
),
});

// Only the public group is under test; the session group (and its SessionAuth
// middleware, which needs a live DB) is out of scope — the callback route lives
// in CloudAuthPublicApi and requires no middleware.
const PublicApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi);

const App = HttpApiBuilder.layer(PublicApi).pipe(
Layer.provide(CloudAuthPublicHandlers),
Layer.provide(stubWorkOS),
Layer.provide(stubUsers),
Layer.provide(HttpServer.layerServices),
);

const run = (request: Request) => {
const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;
// beta.59: the handler type expects a context argument; this layer stack
// needs none at runtime — pass undefined like the api.request-scope tests.
return handler(request, undefined as never);
};

const callbackUrl = (state?: string, code = "code_1") =>
`https://executor.test/auth/callback${state ? `?state=${encodeURIComponent(state)}` : ""}${state ? "&" : "?"}code=${code}`;

describe("workos callback · CSRF state hardening", () => {
it("rejects a callback with NO state (the former bypass) before any WorkOS call", async () => {
const res = await run(new Request(callbackUrl(undefined), { redirect: "manual" }));
expect(res.status).toBe(400);
expect(await res.text()).toContain("Invalid login state");
expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE);
});

it("rejects a state that does not match the login cookie", async () => {
const res = await run(
new Request(callbackUrl("attacker-controlled-state"), { redirect: "manual" }),
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("Invalid login state");
});

it("accepts a fresh state matching the cookie and issues a session (302 + cookie)", async () => {
// /login sets the cookie; simulate its value for this callback.
const state = encodeLoginState({ nonce: "nonce-123", returnTo: "/" });
const res = await run(
new Request(callbackUrl(state), {
headers: { cookie: `${STATE_COOKIE}=${state}` },
redirect: "manual",
}),
);
expect(res.status).toBe(302);
expect(res.headers.get("set-cookie") ?? "").toContain(SESSION_COOKIE);
});

it("rejects a replayed state (single-use contract preserved downstream)", async () => {
// Replay of a state whose cookie is gone (already consumed by the login
// round-trip) must fail closed.
const state = encodeLoginState({ nonce: "nonce-replay", returnTo: "/" });
const first = await run(
new Request(callbackUrl(state), {
headers: { cookie: `${STATE_COOKIE}=${state}` },
redirect: "manual",
}),
);
expect(first.status).toBe(302);

// Second callback: same state, no cookie (session-store consumed it).
const replay = await run(new Request(callbackUrl(state), { redirect: "manual" }));
expect(replay.status).toBe(400);
});
});
Loading
Loading