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
1 change: 1 addition & 0 deletions apps/local/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 17 additions & 5 deletions packages/core/sdk/src/core-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,10 @@ export interface CoreToolsPluginOptions {
* the right org's console (`${webBaseUrl}/<orgSlug>/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 = {}) => ({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
14 changes: 14 additions & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,16 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly
* read surface. Minted connections and their tokens remain per-owner.
*/
readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
/**
* Whether this executor operates in a single-workspace host (local/desktop)
* where all connections are org-scoped and there are no distinct personal
* member accounts. When true:
* - OAuth client registration clamps `owner: "user"` to `owner: "org"`.
* - Agent tools (`oauth.clients.create`, `oauth.clients.registerDynamic`, etc.)
* clamp `owner: "user"` to `owner: "org"`.
* - OAuth start allows existing user-scoped clients to be used by the local connection.
*/
readonly singleWorkspace?: boolean;
/**
* Enable the built-in `core-tools` plugin which contributes agent-facing
* static tools over the v2 surface (integrations / connections / policies).
Expand Down Expand Up @@ -1852,13 +1862,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
)
: Effect.void;

const singleWorkspace = config.singleWorkspace ?? subject === "local";

// Built-in core-tools plugin: agent-facing static tools over the v2 surface.
const plugins: readonly AnyPlugin[] = config.coreTools
? ([
coreToolsPlugin({
webBaseUrl: config.coreTools.webBaseUrl,
orgSlug: config.coreTools.orgSlug,
includeProviders: config.coreTools.includeProviders,
singleWorkspace,
}),
...userPlugins,
] as readonly AnyPlugin[])
Expand Down Expand Up @@ -6003,6 +6016,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
owner: ownerBinding,
tenant,
subject,
singleWorkspace,
ownedKeys: (owner: Owner) => ownedKeys(owner),
defaultWritableProvider,
mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),
Expand Down
44 changes: 44 additions & 0 deletions packages/core/sdk/src/oauth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
55 changes: 44 additions & 11 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand All @@ -880,15 +885,17 @@ 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);
}

yield* deps.fuma
.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));
Expand Down Expand Up @@ -950,21 +957,28 @@ 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))),
}),
);

const removedRow = yield* findClientRow;
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);
Expand Down Expand Up @@ -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)),
);
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [
readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
readonly enterpriseManagedRollout?: ExecutorConfig<TPlugins>["enterpriseManagedRollout"];
readonly singleWorkspace?: boolean;
};

export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
Expand Down Expand Up @@ -176,6 +177,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea
oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
firstPartyOAuthClients: options?.firstPartyOAuthClients,
enterpriseManagedRollout: options?.enterpriseManagedRollout,
...(options?.singleWorkspace !== undefined ? { singleWorkspace: options.singleWorkspace } : {}),
};
};

Expand Down
Loading