Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ src/generated/agent-sdk-manifest.ts
*.sublime-*
dist/

# Isolated local preparation and verification artifacts
.artifacts/

# Eval results
tests/eval-results/
.next/
Expand Down
105 changes: 105 additions & 0 deletions src/integrations/ruby/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { run } from './index.js';
import { initializeAgent, runAgent } from '../../lib/agent-interface.js';
import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js';
import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js';
import type { InstallerOptions } from '../../utils/types.js';

vi.mock('../../lib/agent-interface.js', () => ({ initializeAgent: vi.fn(), runAgent: vi.fn() }));
vi.mock('../../utils/ui-utils.js', () => ({ getOrAskForWorkOSCredentials: vi.fn() }));
vi.mock('../../lib/workos-management.js', () => ({ autoConfigureWorkOSEnvironment: vi.fn() }));
vi.mock('../../lib/skills-assets.js', () => ({ getReference: vi.fn(async () => 'Pinned Ruby reference') }));
vi.mock('../../utils/analytics.js', () => ({ analytics: { capture: vi.fn(), shutdown: vi.fn() } }));

let directory: string;
let options: InstallerOptions;
beforeEach(async () => {
vi.clearAllMocks();
vi.stubGlobal(
'fetch',
vi.fn(() => {
throw new Error('Unexpected network');
}),
);
directory = await mkdtemp(join(tmpdir(), 'ruby-integration-'));
options = { installDir: directory, debug: false, forceInstall: false, local: false, ci: true, skipAuth: true };
await mkdir(join(directory, 'config'));
await writeFile(join(directory, 'config/puma.rb'), 'port ENV.fetch("PORT", 4100)');
vi.mocked(getOrAskForWorkOSCredentials).mockResolvedValue({
apiKey: 'sk_test_synthetic',
clientId: 'client_synthetic',
});
vi.mocked(runAgent).mockResolvedValue({});
});
afterEach(async () => {
vi.unstubAllGlobals();
await rm(directory, { recursive: true, force: true });
});

describe('real Ruby integration with fake agent and credentials', () => {
it.each([undefined, 'http://app.fizzy.localhost:3006/workos/callback'])(
'uses one callback and origin (%s)',
async (redirectUri) => {
const callback = redirectUri ?? 'http://localhost:4100/auth/callback';
const origin = new URL(callback).origin;
const summary = await run({ ...options, redirectUri });
const prompt = vi.mocked(runAgent).mock.calls[0][1];
expect(prompt).toContain(`WORKOS_REDIRECT_URI=${callback}`);
expect(prompt).toContain(`${origin}/auth/login`);
expect(prompt).toContain(`${origin}/`);
expect(summary).toContain(callback);
expect(summary).toContain('not verified');
expect(summary).not.toContain('What the agent did');
expect(autoConfigureWorkOSEnvironment).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
},
);

it.each([false, true])(
'writes selected credentials without putting secrets in the prompt (package.json: %s)',
async (hasPackage) => {
if (hasPackage) await writeFile(join(directory, 'package.json'), '{}');
const file = hasPackage ? '.env.local' : '.env';
await writeFile(join(directory, file), 'OTHER=preserved\n');
await run(options);
const env = await readFile(join(directory, file), 'utf8');
expect(env).toContain('WORKOS_API_KEY=sk_test_synthetic');
expect(env).toContain('WORKOS_CLIENT_ID=client_synthetic');
expect(env).toContain('WORKOS_REDIRECT_URI=http://localhost:4100/auth/callback');
expect(env).toContain('OTHER=preserved');
const prompt = vi.mocked(runAgent).mock.calls[0][1];
expect(prompt).toContain(file);
expect(prompt).toContain('loaded before WorkOS initialization');
expect(prompt).not.toContain('sk_test_synthetic');
expect(await readFile(join(directory, '.gitignore'), 'utf8')).toContain(file);
expect(initializeAgent).toHaveBeenCalledWith(expect.objectContaining({ workingDirectory: directory }), options);
},
);

it('requires real UI/session integration without choosing account policy', async () => {
await run(options);
const prompt = vi.mocked(runAgent).mock.calls[0][1];
for (const requirement of [
'visible sign-in',
'signed-in account',
'repeat login',
'existing authorization',
'account-linking',
'magic-link',
'passkey',
'SDK',
'protected access',
'not global provider-session revocation',
]) {
expect(prompt).toContain(requirement);
}
});

it('does not report success on agent failure', async () => {
vi.mocked(runAgent).mockResolvedValue({ error: 'synthetic failure' });
await expect(run(options)).rejects.toThrow('synthetic failure');
});
});
74 changes: 47 additions & 27 deletions src/integrations/ruby/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { analytics } from '../../utils/analytics.js';
import { INSTALLER_INTERACTION_EVENT_NAME } from '../../lib/constants.js';
import { initializeAgent, runAgent } from '../../lib/agent-interface.js';
import { getOrAskForWorkOSCredentials } from '../../utils/ui-utils.js';
import { autoConfigureWorkOSEnvironment } from '../../lib/workos-management.js';
import { basename } from 'node:path';
import { resolveRedirectUri, getSignInPath } from '../../lib/port-detection.js';
import { buildApplicationSetup } from '../../lib/authkit-application-setup.js';
import { writeCredentialsEnv } from '../../lib/env-writer.js';
import { resolveProjectEnvPath } from '../../lib/project-env.js';
import { getReference } from '../../lib/skills-assets.js';
import { buildSignInSection } from '../../lib/sign-in-route.js';

Expand Down Expand Up @@ -46,15 +50,14 @@ export const config: FrameworkConfig = {
prompts: {},

ui: {
successMessage: 'WorkOS AuthKit integration complete',
successMessage: 'Ruby agent finished; integration verification pending',
getOutroChanges: () => [
'Analyzed your Rails project structure',
'Installed and configured the WorkOS Ruby SDK',
'Created authentication controller with login, callback, and logout',
'Added authentication routes to config/routes.rb',
'Requested SDK configuration, visible auth controls, and application session integration',
'Requested login, callback, and safe logout routes preserving existing authorization',
],
getOutroNextSteps: () => [
'Start your Rails server with `rails server` to test authentication',
'Review the diff and use the project’s documented launcher; a Gemfile does not verify startup behavior',
'Verify visible auth controls, callback identity/account context, repeat login, and protected access after logout',
'Visit the WorkOS Dashboard to manage users and settings',
],
},
Expand All @@ -79,23 +82,25 @@ export async function run(options: InstallerOptions): Promise<string> {
});

// Get WorkOS credentials
const { apiKey, clientId: _clientId } = await getOrAskForWorkOSCredentials(
options,
config.environment.requiresApiKey,
);

// Auto-configure WorkOS environment (redirect URI, CORS, homepage) if not already done
const callerHandledConfig = Boolean(options.apiKey || options.clientId);
if (!callerHandledConfig && apiKey) {
const port = 3000; // Rails default
await autoConfigureWorkOSEnvironment(apiKey, config.metadata.integration, port, {
homepageUrl: options.homepageUrl,
redirectUri: options.redirectUri,
});
}
const { apiKey, clientId } = await getOrAskForWorkOSCredentials(options, config.environment.requiresApiKey);

// The common installer owns URL provisioning after the agent, with a single
// sandbox target and read-back. Never perform legacy pre-agent URL writes here.
const redirectUri = resolveRedirectUri('ruby', options);
const setup = buildApplicationSetup({
clientId,
redirectUri,
homepageUrl: options.homepageUrl,
signInPath: getSignInPath('ruby'),
});
writeCredentialsEnv(options.installDir, {
WORKOS_API_KEY: apiKey,
WORKOS_CLIENT_ID: clientId,
WORKOS_REDIRECT_URI: redirectUri,
});
const envFile = basename(resolveProjectEnvPath(options.installDir));

// Build prompt for the agent
const redirectUri = options.redirectUri || 'http://localhost:3000/auth/callback';
// Keep credentials out of the prompt/transcript; the agent can read the ignored file.
const refContent = await getReference('workos-ruby');
const prompt = `You are integrating WorkOS AuthKit into this Ruby on Rails application.

Expand All @@ -106,7 +111,9 @@ export async function run(options: InstallerOptions): Promise<string> {

## Environment

The following environment variables are needed (create a .env file if one does not exist):
The installer wrote the selected credentials to the gitignored ${envFile}:
Ensure this file is loaded before WorkOS initialization using the project's existing environment-loading convention (Rails does not load dotenv files by itself). Preserve unrelated settings. Never print secrets or commit them. Verify variable presence without displaying values.
The variables are:
- WORKOS_API_KEY
- WORKOS_CLIENT_ID
- WORKOS_REDIRECT_URI=${redirectUri}
Expand All @@ -115,7 +122,17 @@ The following environment variables are needed (create a .env file if one does n

${refContent}

${buildSignInSection(config)}Report your progress using [STATUS] prefixes.
${buildSignInSection(config)}## Application integration requirements (take precedence over generic examples)

- Use callback ${redirectUri}, Initiate login ${setup.initiateLoginUri}, and sign-out return destination ${setup.signOutUri}. CORS origin is ${new URL(redirectUri).origin}. The sign-out return destination is not the logout action. Do not guess a different host/port from Puma when an explicit callback is supplied. Do not write dashboard settings; the installer configures the selected environment after agent execution.
- Add visible sign-in controls while signed out and signed-in account/logout controls in the existing layouts/navigation. Wire real routes, not unused SDK examples. Preserve existing routes; if the required sign-in path conflicts, report the conflict rather than silently replacing it or choosing an unregistered alternative.
- Trace the app's real identity, session, account scope, and active membership/role checks. The callback must establish that existing authenticated context, not merely store an unrelated token or replace current_user. Preserve existing authorization and cross-account boundaries.
- Do not invent account-linking, identity/account auto-creation, membership or role assignment policy. Ask the user for the mapping policy if absent; leave that integration pending rather than using User.find_or_create_by(email:) or a hardcoded identity. Preserve magic-link and passkey behavior; do not silently replace the authentication system.
- Ensure repeat login does not duplicate identities, accounts, or memberships under the approved policy.
- Implement safe logout using the app's session termination/cookie clearing and the installed SDK's supported session logout behavior. Use CSRF protection for local session mutation. Verify protected access is denied afterward, including replay of the old app session. Local cookie deletion is not global provider-session revocation. If the SDK cannot end the upstream session, report that limitation instead of claiming logout is complete.
- Add local route/session tests with synthetic identities and stubbed network where possible. Distinguish source changes from checks actually run; source strings and a successful agent exit are not behavioral proof. Report commands/results, unavailable checks, and pending policy decisions. Hosted AuthKit/browser flows remain unverified until actually exercised.

Report your progress using [STATUS] prefixes.

Begin integration now.`;

Expand Down Expand Up @@ -152,9 +169,12 @@ Begin integration now.`;
const nextSteps = config.ui.getOutroNextSteps({});

const lines: string[] = [
'Successfully installed WorkOS AuthKit!',
'Ruby agent finished. Application behavior and hosted AuthKit flows are not verified.',
`Credentials and callback written to ${envFile}; runtime loading is not verified.`,
`Requested callback: ${redirectUri}`,
'Application URL registration is handled separately by the installer after this step.',
'',
'What the agent did:',
'Instructions given to the agent (not verified changes):',
...changes.map((c) => `• ${c}`),
'',
'Next steps:',
Expand Down
6 changes: 4 additions & 2 deletions src/lib/completion-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ export async function buildCompletionData(ctx: CompletionContext, deps: Completi
const dev = await deps.resolveDevCommand(ctx.installDir);
const devCommand = [dev.command, ...dev.args].join(' ');
const port = deps.detectPort(ctx.integration as Integration, ctx.installDir);
const url = `http://localhost:${port}`;
const url = deps.applicationSetup ? new URL(deps.applicationSetup.redirectUri).origin : `http://localhost:${port}`;
const files = ctx.changedFiles ?? [];

const concrete = [
`Run \`${devCommand}\` to start your dev server`,
ctx.integration === 'ruby'
? `Use the project's documented launcher (inferred command: \`${devCommand}\`; startup not verified)`
: `Run \`${devCommand}\` to start your dev server`,
`Open ${url} to test authentication`,
...(deps.signInSnippet ? [deps.signInSnippet] : []),
];
Expand Down
16 changes: 13 additions & 3 deletions src/lib/env-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const ENV_COVERING_PATTERNS = ['.env', '.env*'];
/**
* Ensure the given filename is in .gitignore.
* Creates .gitignore if it doesn't exist.
* No-ops if one of `coveringPatterns` is already present.
* No-ops if a recognized covering pattern appears after any negations.
*/
function ensureGitignore(installDir: string, filename: string, coveringPatterns: string[]): void {
const gitignorePath = join(installDir, '.gitignore');
Expand All @@ -21,9 +21,14 @@ function ensureGitignore(installDir: string, filename: string, coveringPatterns:
const content = readFileSync(gitignorePath, 'utf-8');
const lines = content.split('\n').map((line) => line.trim());

if (lines.some((line) => coveringPatterns.includes(line))) {
return;
// A later negation can expose a previously covered secret. Conservatively
// append an explicit rule after negations rather than attempting to parse globs.
let covered = false;
for (const line of lines) {
if (line.startsWith('!')) covered = false;
else if (coveringPatterns.includes(line)) covered = true;
}
if (covered) return;

const separator = content.endsWith('\n') ? '' : '\n';
writeFileSync(gitignorePath, `${content}${separator}${filename}\n`);
Expand Down Expand Up @@ -206,6 +211,11 @@ export function writeCredentialsEnv(installDir: string, envVars: Partial<EnvVars
return;
}

writeEnvFile(installDir, envVars);
}

/** Write .env explicitly for non-JS SDKs, even when frontend tooling adds package.json. */
export function writeEnvFile(installDir: string, envVars: Partial<EnvVars>): void {
const envPath = join(installDir, '.env');

backupEnvFile(installDir, envPath);
Expand Down
Loading
Loading