Skip to content
Draft
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
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"fixed": [
[
"@executor-js/sdk",
"@executor-js/product-access",
"@executor-js/codemode-core",
"@executor-js/runtime-quickjs",
"@executor-js/execution",
Expand Down
21 changes: 21 additions & 0 deletions .changeset/product-access-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"executor": minor
---

Extract personal/organization access rules from the core SDK into the new
`@executor-js/product-access` package. BREAKING for SDK embedders:
`createExecutor` (Effect and Promise APIs alike) now REQUIRES
`access: ExecutorAccess` — the product's decisions for row visibility and
write partitions (`owners`), user-intent settings authorization
(`settingsWrite`), view capabilities (`adminReads` / `storageWrites`), and
effective tool-policy evaluation (`toolPolicy`). The former `orgWrites` and
`platformView` options are removed; select a posture from
`@executor-js/product-access` (`singleUserAccess()`,
`workspaceServiceAccess()`, `memberAccessForRole(...)`,
`requestBoundMemberAccess()`, `platformObserverAccess()`) or supply your own.
Core keeps enforcing tenant isolation, the storage owner policy clamps, and
approval mechanics; it no longer decides any product rule. Test helpers
changed too: `makeTestConfig` and the workspace harness require `access`
(postures in `@executor-js/product-access/testing`). No stored schema or data
migration — persisted rows, tenants, owners, subjects and addresses are
unchanged.
1 change: 1 addition & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ jobs:
'./apps/cli/dist/executor'
'./packages/core/storage-core'
'./packages/core/sdk'
'./packages/core/product-access'
'./packages/core/config'
'./packages/core/execution'
'./packages/core/cli'
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,18 @@ Embed Executor with the TypeScript SDK (a Promise API; an Effect-native API is
also available):

```ts
import { createExecutor } from "@executor-js/sdk/promise";
import { openApiPlugin } from "@executor-js/plugin-openapi/promise";

const executor = await createExecutor({ plugins: [openApiPlugin()] });
import { workspaceServiceAccess } from "@executor-js/product-access";
import { createExecutor } from "@executor-js/sdk";
import { openApiPlugin } from "@executor-js/plugin-openapi";

const executor = await createExecutor({
plugins: [openApiPlugin()],
onElicitation: "accept-all",
// Product access decisions are REQUIRED and stated explicitly — the SDK
// ships no default posture. `@executor-js/product-access` has the standard
// ones (this is the subject-less single-workspace posture).
access: workspaceServiceAccess(),
});

// add an integration, create a connection, then list and call tools
const tools = await executor.tools.list({ integration: "inventory" });
Expand Down
1 change: 1 addition & 0 deletions apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/plugin-toolkits": "workspace:*",
"@executor-js/plugin-workos-vault": "workspace:*",
"@executor-js/product-access": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-dynamic-worker": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/admin/admin-users-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// acting member, and this plane deliberately serves the whole tenant.
//
// The executor is built by `makePlatformExecutor` — `{ tenant, subject:
// undefined, platformView: true }` — so the reads are tenant-wide and read-only
// undefined, access: platformObserverAccess() }` — so the reads are tenant-wide and read-only
// by storage policy, and no `subject` row is minted for the caller.
//
// Cross-tenant isolation is structural, not a check in this file: the tenant is
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/auth/workos-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
* `subject: null` + the read-only tenant reach rather than inventing a subject.
*
* The `/admin/*` mount turns this into an executor with `{ tenant:
* organizationId, subject: undefined, platformView: true }`.
* organizationId, subject: undefined, access: platformObserverAccess() }`.
*/
export interface PlatformAuth {
readonly kind: "platform";
Expand Down
2 changes: 2 additions & 0 deletions apps/cloud/src/mcp-session.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/test
import executorConfig from "../executor.config";
import { DbService } from "./db/db";
import { createDrizzleFumaDb } from "./db/fuma";
import { testAccess } from "@executor-js/product-access/testing";

// ---------------------------------------------------------------------------
// Test-only plugin: exposes one in-memory tool that elicits once. Lets the
Expand Down Expand Up @@ -124,6 +125,7 @@ const buildScopedExecutor = (
plugins,
httpClientLayer: FetchHttpClient.layer,
onElicitation: "accept-all",
access: testAccess.member(),
});
});

Expand Down
3 changes: 2 additions & 1 deletion apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "@executor-js/host-mcp/tool-server";
import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
import { requestBoundMemberAccess } from "@executor-js/product-access";
import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
import {
Expand Down Expand Up @@ -349,7 +350,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
sessionMeta.organizationName,
{
mcpResource: sessionMeta.resource,
orgWrites: "request",
access: requestBoundMemberAccess(),
},
).pipe(
// The metered stack tracks each execution to Autumn. It requires
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { createExecutor } from "@executor-js/sdk";
import { makeTestConfig } from "@executor-js/sdk/testing";

import { UrlRedactingSpanProcessor } from "./redact-span-urls";
import { testAccess } from "@executor-js/product-access/testing";

// Synthetic placeholders — never a real authorization code or CSRF state.
const CODE = "synthetic-authorization-code-9f2c";
Expand All @@ -55,7 +56,7 @@ describe("oauth callback telemetry", () => {
it.effect("exports no span attribute containing the authorization code or state", () =>
Effect.gen(function* () {
const { exporter, provider, tracerLayer } = makeTracing();
const executor = yield* createExecutor(makeTestConfig({}));
const executor = yield* createExecutor(makeTestConfig({ access: testAccess.member() }));

const web = yield* Effect.acquireRelease(
Effect.sync(() =>
Expand Down
1 change: 1 addition & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/plugin-provider-service-split": "workspace:*",
"@executor-js/plugin-toolkits": "workspace:*",
"@executor-js/product-access": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-dynamic-worker": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact";
import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker";
import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
import type { ExecutorDbHandle } from "@executor-js/api/server";
import { requestBoundMemberAccess } from "@executor-js/product-access";
import {
McpAgentSessionDOBase,
type BuiltMcpServer,
Expand Down Expand Up @@ -151,7 +152,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
{ mcpResource: sessionMeta.resource, orgWrites: "request" },
{ mcpResource: sessionMeta.resource, access: requestBoundMemberAccess() },
).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
// Browser elicitation mode (the base owns the approval store + the HTTP
// approval RPCs): a gated execution pauses and returns an approvalUrl into
Expand Down
1 change: 1 addition & 0 deletions apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/plugin-provider-service-split": "workspace:*",
"@executor-js/plugin-toolkits": "workspace:*",
"@executor-js/product-access": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
Expand Down
7 changes: 4 additions & 3 deletions apps/host-selfhost/src/integrations-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
import { mintInviteCode } from "./testing/mint-invite";
import { SelfHostScopedExecutorSeams } from "./execution";
import type { SelfHostPlugins } from "./plugins";
import { memberAccess } from "@executor-js/product-access";

// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
Expand All @@ -22,9 +23,9 @@ const createScopedExecutor = (
organizationId: string,
organizationName: string,
) =>
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
Effect.provide(SelfHostScopedExecutorSeams),
);
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
access: memberAccess("allowed"),
}).pipe(Effect.provide(SelfHostScopedExecutorSeams));

// End-to-end: an org-owned connection's tools are reachable from a user's MCP
// `execute` sandbox.
Expand Down
7 changes: 4 additions & 3 deletions apps/host-selfhost/src/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server";
import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
import { SelfHostScopedExecutorSeams } from "./execution";
import type { SelfHostPlugins } from "./plugins";
import { memberAccess } from "@executor-js/product-access";

// The self-host scoped-executor seams (DbProvider over the long-lived SelfHostDb,
// fresh per-request plugins, host config) over the shared `makeScopedExecutor`,
Expand All @@ -21,9 +22,9 @@ const createScopedExecutor = (
organizationId: string,
organizationName: string,
) =>
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
Effect.provide(SelfHostScopedExecutorSeams),
);
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
access: memberAccess("allowed"),
}).pipe(Effect.provide(SelfHostScopedExecutorSeams));

const dataDir = mkdtempSync(join(tmpdir(), "eh-src-"));
process.env.EXECUTOR_DATA_DIR = dataDir;
Expand Down
7 changes: 4 additions & 3 deletions apps/host-selfhost/src/secrets-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { makeScopedExecutor } from "@executor-js/api/server";
import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
import { SelfHostScopedExecutorSeams } from "./execution";
import type { SelfHostPlugins } from "./plugins";
import { memberAccess } from "@executor-js/product-access";

// In v2 a connection IS the credential: its inline `value` is written through the
// default writable provider — here the encrypted-secrets provider, which stores
Expand All @@ -28,9 +29,9 @@ const createScopedExecutor = (
organizationId: string,
organizationName: string,
) =>
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName).pipe(
Effect.provide(SelfHostScopedExecutorSeams),
);
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, organizationName, {
access: memberAccess("allowed"),
}).pipe(Effect.provide(SelfHostScopedExecutorSeams));

let dbLayer!: Layer.Layer<SelfHostDb>;
let dbHandle: Awaited<ReturnType<typeof createSelfHostDb>> | undefined;
Expand Down
7 changes: 4 additions & 3 deletions apps/host-selfhost/src/subject-sightings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { makeScopedExecutor } from "@executor-js/api/server";
import { createSelfHostDb, SelfHostDb } from "./db/self-host-db";
import { SelfHostScopedExecutorSeams } from "./execution";
import type { SelfHostPlugins } from "./plugins";
import { memberAccess } from "@executor-js/product-access";

// The `subject` table is populated at the request seam: `makeScopedExecutor` is
// what every HTTP request and MCP session on every host passes through, so
Expand All @@ -19,9 +20,9 @@ import type { SelfHostPlugins } from "./plugins";
// the point is that the production wiring reaches the writer at all.

const createScopedExecutor = (accountId: string, organizationId: string) =>
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default").pipe(
Effect.provide(SelfHostScopedExecutorSeams),
);
makeScopedExecutor<SelfHostPlugins>(accountId, organizationId, "Default", {
access: memberAccess("allowed"),
}).pipe(Effect.provide(SelfHostScopedExecutorSeams));

const dataDir = mkdtempSync(join(tmpdir(), "eh-subj-"));
process.env.EXECUTOR_DATA_DIR = dataDir;
Expand Down
1 change: 1 addition & 0 deletions apps/local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/plugin-provider-service-split": "workspace:*",
"@executor-js/plugin-toolkits": "workspace:*",
"@executor-js/product-access": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/auth-tool-failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
createExecutor,
} from "@executor-js/sdk";
import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
import { testAccess } from "@executor-js/product-access/testing";

import { ErrorCaptureLive } from "./observability";
import { createSqliteFumaDb } from "./db/sqlite-fumadb";
Expand Down Expand Up @@ -109,6 +110,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => {
db: sqlite.db,
plugins,
onElicitation: "accept-all",
access: testAccess.member(),
}),
);

Expand Down
16 changes: 12 additions & 4 deletions apps/local/src/db/legacy-bigint-boot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ import { Effect } from "effect";
import { withQueryContext } from "@executor-js/fumadb/query";

import { collectTables } from "@executor-js/api/server";
import { runSqliteDataMigrations } from "@executor-js/sdk";
import { runSqliteDataMigrations, type ExecutorOwnerPolicyContext } from "@executor-js/sdk";

import { localDataMigrations } from "./data-migrations";
import { createSqliteFumaDb } from "./sqlite-fumadb";

const TENANT = "executor-workspace-1771";
const SUBJECT = "local";
// The fixture's inspection context: the local single-user binding's full
// identity view, with its owner partitions stated explicitly — the owner
// policy has no full-view default, and the seeded row is a `user` row.
const FIXTURE_CONTEXT: ExecutorOwnerPolicyContext = {
tenant: TENANT,
subject: SUBJECT,
owners: ["user", "org"],
};
// Epoch millis, the shape an OAuth token expiry takes.
const LEGACY_EXPIRES_AT = 1787321623456;

Expand Down Expand Up @@ -71,7 +79,7 @@ describe("local boot over a legacy bigint database", () => {
await seedLegacyConnection(dbPath);

const sqlite = await openDb(dbPath);
const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
// The reported symptom: not a wrong value, a throw — so the gateway lost
// every saved integration at once.
await expect(scoped.findMany("connection", {})).rejects.toThrow(/type number/);
Expand All @@ -88,7 +96,7 @@ describe("local boot over a legacy bigint database", () => {
);
expect(applied).toContain("2026-08-28-bigint-storage-class");

const scoped = withQueryContext(sqlite.db, { tenant: TENANT, subject: SUBJECT });
const scoped = withQueryContext(sqlite.db, FIXTURE_CONTEXT);
const rows = await scoped.findMany("connection", {});
expect(rows.map((row) => [row.name, Number(row.expires_at)])).toEqual([
["default", LEGACY_EXPIRES_AT],
Expand All @@ -112,7 +120,7 @@ describe("local boot over a legacy bigint database", () => {
);
expect(applied).not.toContain("2026-08-28-bigint-storage-class");

const scoped = withQueryContext(second.db, { tenant: TENANT, subject: SUBJECT });
const scoped = withQueryContext(second.db, FIXTURE_CONTEXT);
const rows = await scoped.findMany("connection", {});
expect(rows.map((row) => Number(row.expires_at))).toEqual([LEGACY_EXPIRES_AT]);
await second.close();
Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/db/v1-v2-boot-drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
runSqliteDataMigrations,
} from "@executor-js/sdk";
import { memoryCredentialsPlugin } from "@executor-js/sdk/testing";
import { testAccess } from "@executor-js/product-access/testing";
import { openApiPlugin } from "@executor-js/plugin-openapi";
import { serveOpenApiHttpApiTestServer } from "@executor-js/plugin-openapi/testing";
import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
Expand Down Expand Up @@ -123,6 +124,7 @@ const bootRealStack = async (dbPath: string) => {
memoryCredentialsPlugin(),
] as const,
onElicitation: "accept-all",
access: testAccess.member(),
}),
);
return {
Expand Down
4 changes: 4 additions & 0 deletions apps/local/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type Executor,
} from "@executor-js/sdk";
import { collectTables } from "@executor-js/api/server";
import { singleUserAccess } from "@executor-js/product-access";
import { loadPluginsFromJsonc } from "@executor-js/config";
import type { McpPluginExtension } from "@executor-js/plugin-mcp";

Expand Down Expand Up @@ -220,6 +221,9 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
{ plugin_key: event.pluginKey },
),
onElicitation: "accept-all",
// Single-user product: one human owns the deployment and may
// configure everything (see @executor-js/product-access).
access: singleUserAccess(),
oauthEndpointUrlPolicy: { allowHttp: true },
// EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`
// route on the same origin as the web UI. Derived from `webBaseUrl`
Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/mcp-browser-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {

import { createMcpRequestHandler } from "./mcp";
import { createSqliteFumaDb } from "./db/sqlite-fumadb";
import { testAccess } from "@executor-js/product-access/testing";

const TEST_BASE_URL = "http://local.test";

Expand Down Expand Up @@ -90,6 +91,7 @@ const makeExecutor = async (tmpDir: string): Promise<Executor> => {
db: sqlite.db,
plugins,
onElicitation: "accept-all",
access: testAccess.member(),
}),
);

Expand Down
2 changes: 2 additions & 0 deletions apps/local/src/mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
createExecutor,
} from "@executor-js/sdk";
import { serveOAuthTestServer } from "@executor-js/sdk/testing";
import { testAccess } from "@executor-js/product-access/testing";
import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
import { mcpPlugin } from "@executor-js/plugin-mcp";
import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api";
Expand Down Expand Up @@ -98,6 +99,7 @@ const startHarness = async (tmpDir: string): Promise<Harness> => {
db: sqlite.db,
plugins,
onElicitation: "accept-all",
access: testAccess.member(),
oauthEndpointUrlPolicy: { allowHttp: true },
// EXPLICIT OAuth callback — required now that the localhost default is
// gone; the local daemon serves `/api/oauth/callback` on the web origin.
Expand Down
Loading
Loading