diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 1ec7ebbf6..2a02dadca 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -226,6 +226,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { // (loopback localhost is correct + intended for the local CLI, but it // is wired explicitly here rather than relying on a hidden default). redirectUri: new URL("/api/oauth/callback", webBaseUrl).toString(), + singleWorkspace: true, // Built-in agent-facing tools (integrations / connections / policies). coreTools: { webBaseUrl, diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 82e2b8fe5..af7d65803 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -546,6 +546,10 @@ export interface CoreToolsPluginOptions { * the right org's console (`${webBaseUrl}//integrations/...`). */ readonly orgSlug?: string; readonly includeProviders?: boolean; + /** Whether the host is a single-workspace deployment (local/desktop) where + * all resources are org/local-scoped. When true, user-scoped client writes + * are clamped to org scope. */ + readonly singleWorkspace?: boolean; } export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {}) => ({ @@ -820,7 +824,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) => Effect.map( ctx.oauth.createClient({ - owner: input.owner as Owner, + owner: (options.singleWorkspace ? "org" : input.owner) as Owner, slug: OAuthClientSlug.make(input.slug), authorizationUrl: input.authorizationUrl, tokenUrl: input.tokenUrl, @@ -852,7 +856,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // path (it routes the secret to the human in the browser), so it is // deliberately NOT approval-gated, mirroring `connections.createHandoff`. execute: (input: typeof OAuthCreateClientHandoffInput.Type) => { - const url = oauthClientCreateHandoffUrl(options.webBaseUrl, options.orgSlug, input); + const effectiveOwner = options.singleWorkspace + ? "org" + : (input.owner as Owner | undefined); + const url = oauthClientCreateHandoffUrl(options.webBaseUrl, options.orgSlug, { + ...input, + ...(effectiveOwner !== undefined ? { owner: effectiveOwner } : {}), + }); return Effect.succeed({ url, instructions: @@ -873,7 +883,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) => Effect.map( ctx.oauth.registerDynamicClient({ - owner: input.owner as Owner, + owner: (options.singleWorkspace ? "org" : input.owner) as Owner, slug: OAuthClientSlug.make(input.slug), issuer: input.issuer ?? null, registrationEndpoint: input.registrationEndpoint, @@ -905,7 +915,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { annotations: { requiresApproval: true }, execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) => Effect.gen(function* () { - const owner = input.owner as Owner; + const owner = (options.singleWorkspace ? "org" : input.owner) as Owner; const slug = OAuthClientSlug.make(input.slug); // `removeClient` is idempotent by design at the storage layer, so // on its own it cannot distinguish a real deletion from a typo'd @@ -914,7 +924,9 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // Checking the visible set first is what keeps `removed` honest. const clients = yield* ctx.oauth.listClients(); const matched = clients.some( - (client) => client.owner === owner && String(client.slug) === String(slug), + (client) => + (options.singleWorkspace || client.owner === owner) && + String(client.slug) === String(slug), ); if (!matched) return { removed: false }; yield* ctx.oauth.removeClient(owner, slug); diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 377973a7f..8cc026e81 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -619,6 +619,51 @@ describe("createExecutor", () => { ), ); + it.effect( + "single-workspace coreTools clamps oauth.clients.create owner: 'user' to 'org' and removes it cleanly", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, + singleWorkspace: true, + }); + yield* executor.demo.seed(); + + const client = "demo-local-app"; + const created = yield* executor.execute( + ToolAddress.make("executor.coreTools.oauth.clients.create"), + { + owner: "user", + slug: client, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + }, + ); + expect(created).toEqual({ client }); + + const clients = yield* executor.oauth.listClients(); + const found = clients.find((c) => String(c.slug) === client); + expect(found).toBeDefined(); + expect(found?.owner).toBe("org"); + + // Removing via coreTools with owner: 'user' or 'org' succeeds + const removed = yield* executor.execute( + ToolAddress.make("executor.coreTools.oauth.clients.remove"), + { + owner: "user", + slug: client, + }, + ); + expect(removed).toEqual({ removed: true }); + }), + ), + ); + it.effect("orders integration detection results by confidence", () => Effect.gen(function* () { const plugins = [ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 653ab5701..2dc4cf499 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -728,6 +728,16 @@ export interface ExecutorConfig ownedKeys(owner), defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index a4f7aff41..359d5b1e7 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -878,6 +878,50 @@ describe("oauth.start / oauth.complete", () => { }), ), ); + + it.effect( + "on a single-workspace host (singleWorkspace: true), user client ownership is clamped to org and usable by local connections", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ + plugins, + singleWorkspace: true, + }); + const { executor } = harness; + yield* executor.acme.seed(); + + // Registering with owner: "user" on a single-workspace host clamps to org + const registered = yield* executor.oauth.createClient({ + owner: "user", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + expect(registered).toEqual(CLIENT); + + const clients = yield* executor.oauth.listClients(); + const client = clients.find((c) => String(c.slug) === String(CLIENT)); + expect(client).toBeDefined(); + expect(client?.owner).toBe("org"); + + // Starting a flow with clientOwner: "user" or "org" succeeds and does NOT throw "must use a Workspace app" + const started = yield* executor.oauth.start({ + owner: "org", + clientOwner: "user", + client: CLIENT, + name: ConnectionName.make("local-conn"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + }), + ), + ); }); describe("oauth token refresh in resolveConnectionValue", () => { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 5d7b0ef8a..01b71b9d2 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -258,6 +258,10 @@ export interface OAuthServiceDeps { * client CRUD surface rejects the namespace. Empty/omitted on hosts that * ship no first-party apps. */ readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[]; + /** Whether the host is a single-workspace deployment (local/desktop) where + * all resources are org/local-scoped. When true, user client ownership is + * clamped to org scope and local connections can use any local client. */ + readonly singleWorkspace?: boolean; } type LooseDb = { @@ -857,8 +861,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } yield* validateClientEndpoints(input, deps.endpointUrlPolicy); + const clientOwner: Owner = deps.singleWorkspace ? "org" : input.owner; const keys = yield* Effect.try({ - try: () => deps.ownedKeys(input.owner), + try: () => deps.ownedKeys(clientOwner), catch: (cause) => new StorageError({ message: "Cannot write oauth_client for owner without a subject", @@ -880,7 +885,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } - clientSecretItemIdValue = clientSecretItemId(input.owner, input.slug); + clientSecretItemIdValue = clientSecretItemId(clientOwner, input.slug); yield* provider.set(ProviderItemId.make(clientSecretItemIdValue), input.clientSecret); } @@ -888,7 +893,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { .use("oauth_client.deleteExisting", (db) => looseDb(db).deleteMany("oauth_client", { where: (b: any) => - b.and(b("owner", "=", input.owner), b("slug", "=", String(input.slug))), + deps.singleWorkspace + ? b("slug", "=", String(input.slug)) + : b.and(b("owner", "=", clientOwner), b("slug", "=", String(input.slug))), }), ) .pipe(Effect.catch(() => Effect.void)); @@ -950,13 +957,17 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + const clientOwner: Owner = deps.singleWorkspace ? "org" : owner; // "Is there an app at (owner, slug) right now?" — asked twice, for two // different reasons. Before the delete it says whether this call removes // anything at all; after the commit it says whether the secret key still // belongs to the app this call removed. const findClientRow = deps.fuma.use("oauth_client.findFirst", (db) => looseDb(db).findFirst("oauth_client", { - where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + where: (b: any) => + deps.singleWorkspace + ? b("slug", "=", String(slug)) + : b.and(b("owner", "=", clientOwner), b("slug", "=", String(slug))), }), ); @@ -964,7 +975,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { yield* deps.fuma .use("oauth_client.delete", (db) => looseDb(db).deleteMany("oauth_client", { - where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + where: (b: any) => + deps.singleWorkspace + ? b("slug", "=", String(slug)) + : b.and(b("owner", "=", clientOwner), b("slug", "=", String(slug))), }), ) .pipe(Effect.asVoid); @@ -1000,7 +1014,17 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // an orphaned secret is recoverable, a destroyed live one is not. const recreated = yield* findClientRow; if (recreated) return; - yield* dropSecret.call(provider, ProviderItemId.make(clientSecretItemId(owner, slug))); + yield* dropSecret.call( + provider, + ProviderItemId.make( + clientSecretItemId( + (deps.singleWorkspace + ? "org" + : ((removedRow.owner as Owner | undefined) ?? clientOwner)) as Owner, + slug, + ), + ), + ); }).pipe(Effect.catch(() => Effect.void)), ); } @@ -1318,7 +1342,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ); } return Effect.succeed({ - owner: String(row.owner) as Owner, + owner: (deps.singleWorkspace ? "org" : (String(row.owner) as Owner)) as Owner, slug: OAuthClientSlug.make(String(row.slug)), grant, authorizationUrl: String(row.authorization_url), @@ -1347,10 +1371,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const config = firstPartyBySlug.get(String(slug)); return Effect.succeed(config ? loadedFirstPartyClient(config) : null); } + const clientOwner: Owner = deps.singleWorkspace ? "org" : owner; return deps.fuma .use("oauth_client.findFirst", (db) => looseDb(db).findFirst("oauth_client", { - where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), + where: (b: any) => + deps.singleWorkspace + ? b("slug", "=", String(slug)) + : b.and(b("owner", "=", clientOwner), b("slug", "=", String(slug))), }), ) .pipe( @@ -1422,9 +1450,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { "executor.oauth.client_first_party": firstPartyFlow, }); if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { - return yield* new OAuthStartError({ - message: "A Workspace connection must use a Workspace app.", - }); + if (deps.singleWorkspace) { + // On single-workspace hosts (local/desktop), all resources are owned by + // the single local actor; cross-owner restrictions do not apply. + } else { + return yield* new OAuthStartError({ + message: "A Workspace connection must use a Workspace app.", + }); + } } // Load the app by its EXPLICIT owner (the caller knows it — no derivation). // The connection is still minted under `input.owner`. Storage visibility diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index cbb64e1da..3bd5ce1d5 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -134,6 +134,7 @@ export type TestConfigOptions["onIntegrationChange"]; readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; + readonly singleWorkspace?: boolean; }; export const makeTestConfig = ( @@ -176,6 +177,7 @@ export const makeTestConfig =