Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ jobs:
TF_VAR_modal_token_id: ${{ secrets.MODAL_TOKEN_ID }}
TF_VAR_modal_token_secret: ${{ secrets.MODAL_TOKEN_SECRET }}
TF_VAR_modal_workspace: ${{ secrets.MODAL_WORKSPACE }}
TF_VAR_modal_environment: "${{ secrets.MODAL_ENVIRONMENT || 'main' }}"
TF_VAR_modal_environment_web_suffix: ${{ secrets.MODAL_ENVIRONMENT_WEB_SUFFIX }}
TF_VAR_github_client_id: ${{ secrets.GH_OAUTH_CLIENT_ID }}
TF_VAR_github_client_secret: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
TF_VAR_github_app_id: ${{ secrets.GH_APP_ID }}
Expand Down Expand Up @@ -319,6 +321,8 @@ jobs:
TF_VAR_modal_token_id: ${{ secrets.MODAL_TOKEN_ID }}
TF_VAR_modal_token_secret: ${{ secrets.MODAL_TOKEN_SECRET }}
TF_VAR_modal_workspace: ${{ secrets.MODAL_WORKSPACE }}
TF_VAR_modal_environment: "${{ secrets.MODAL_ENVIRONMENT || 'main' }}"
TF_VAR_modal_environment_web_suffix: ${{ secrets.MODAL_ENVIRONMENT_WEB_SUFFIX }}
TF_VAR_github_client_id: ${{ secrets.GH_OAUTH_CLIENT_ID }}
TF_VAR_github_client_secret: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
TF_VAR_github_app_id: ${{ secrets.GH_APP_ID }}
Expand Down
93 changes: 52 additions & 41 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,11 @@ Create an R2 API Token:
1. Go to [Modal Settings](https://modal.com/settings)
2. **Create a new API token**: Settings -> API Tokens -> New Token
3. Note the **Token ID** and **Token Secret**
4. Note your **Workspace name** (visible in your Modal dashboard URL)
4. Note your **Workspace** and **Environment name** (visible in your Modal dashboard URL,
https://modal.com/apps/<modal_workspace>/<modal_environment>)
5. Note the environment's **Web suffix** from Modal's environment settings. Use the normalized
lowercase suffix made of letters, digits, and dashes. Leave it empty for the environment whose
endpoints use `https://<workspace>--...modal.run`.

### Daytona

Expand Down Expand Up @@ -355,6 +359,8 @@ vercel_team_id = "team_xxxxx" # Your Vercel ID (even personal
modal_token_id = "your-modal-token-id"
modal_token_secret = "your-modal-token-secret"
modal_workspace = "your-modal-workspace"
modal_environment = "your-modal-environment"
modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digits, dashes; empty for https://workspace--... endpoints

# Daytona (only required when sandbox_provider = "daytona")
# daytona_api_url = "https://app.daytona.io/api"
Expand Down Expand Up @@ -628,8 +634,11 @@ Or manually:
# 1. Control Plane health check (replace {deployment_name} and YOUR-SUBDOMAIN)
curl https://open-inspect-control-plane-{deployment_name}.YOUR-SUBDOMAIN.workers.dev/health

# 2. Modal health check (replace YOUR-WORKSPACE)
curl https://YOUR-WORKSPACE--open-inspect-api-health.modal.run
# 2. Modal health check
# Prefer the exact URL from terraform output verification_commands.
# Manual form: https://<workspace>[-<modal_environment_web_suffix>]--open-inspect-api-health.modal.run
MODAL_WORKSPACE_SLUG="YOUR-WORKSPACE" # or "YOUR-WORKSPACE-YOUR-MODAL-WEB-SUFFIX"
curl https://${MODAL_WORKSPACE_SLUG}--open-inspect-api-health.modal.run

# 3. Web app (should return 200)
# Vercel:
Expand All @@ -653,44 +662,46 @@ Enable automatic deployments when you push to main by adding GitHub Secrets.

Go to your fork's Settings → Secrets and variables → Actions, and add:

| Secret Name | Value |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token |
| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID |
| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain |
| `DEPLOYMENT_NAME` | Your deployment name |
| `R2_ACCESS_KEY_ID` | R2 access key ID |
| `R2_SECRET_ACCESS_KEY` | R2 secret access key |
| `WEB_PLATFORM` | `vercel` or `cloudflare` |
| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ |
| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ |
| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ |
| `NEXTAUTH_URL` | Your web app URL |
| `MODAL_TOKEN_ID` | Modal token ID |
| `MODAL_TOKEN_SECRET` | Modal token secret |
| `MODAL_WORKSPACE` | Modal workspace name |
| `GH_OAUTH_CLIENT_ID` | GitHub App OAuth client ID |
| `GH_OAUTH_CLIENT_SECRET` | GitHub App OAuth client secret |
| `GH_APP_ID` | GitHub App ID |
| `GH_APP_PRIVATE_KEY` | GitHub App private key (PKCS#8 format) |
| `GH_APP_INSTALLATION_ID` | GitHub App installation ID |
| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) |
| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) |
| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) |
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) |
| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) |
| `INTERNAL_CALLBACK_SECRET` | Generated callback secret |
| `MODAL_API_SECRET` | Generated Modal API secret |
| `NEXTAUTH_SECRET` | Generated NextAuth secret |
| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) |
| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) |
| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) |
| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) |
| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) |
| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) |
| `APP_SHORT_NAME` | Optional short label for sidebar header (default: `Inspect`) |
| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) |
| Secret Name | Value |
| ------------------------------ | ------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token |
| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID |
| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain |
| `DEPLOYMENT_NAME` | Your deployment name |
| `R2_ACCESS_KEY_ID` | R2 access key ID |
| `R2_SECRET_ACCESS_KEY` | R2 secret access key |
| `WEB_PLATFORM` | `vercel` or `cloudflare` |
| `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ |
| `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ |
| `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ |
| `NEXTAUTH_URL` | Your web app URL |
| `MODAL_TOKEN_ID` | Modal token ID |
| `MODAL_TOKEN_SECRET` | Modal token secret |
| `MODAL_WORKSPACE` | Modal workspace name |
| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) |
| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty |
| `GH_OAUTH_CLIENT_ID` | GitHub App OAuth client ID |
| `GH_OAUTH_CLIENT_SECRET` | GitHub App OAuth client secret |
| `GH_APP_ID` | GitHub App ID |
| `GH_APP_PRIVATE_KEY` | GitHub App private key (PKCS#8 format) |
| `GH_APP_INSTALLATION_ID` | GitHub App installation ID |
| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) |
| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) |
| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) |
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) |
| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) |
| `INTERNAL_CALLBACK_SECRET` | Generated callback secret |
| `MODAL_API_SECRET` | Generated Modal API secret |
| `NEXTAUTH_SECRET` | Generated NextAuth secret |
| `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) |
| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) |
| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) |
| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) |
| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) |
| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) |
| `APP_SHORT_NAME` | Optional short label for sidebar header (default: `Inspect`) |
| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) |

**Bulk upload secrets with `gh` CLI:**

Expand Down
12 changes: 10 additions & 2 deletions packages/control-plane/src/routes/repo-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ async function handleBuildComplete(
ctx.executionCtx?.waitUntil(
(async () => {
try {
const client = createModalClient(env.MODAL_API_SECRET!, env.MODAL_WORKSPACE!);
const client = createModalClient(
env.MODAL_API_SECRET!,
env.MODAL_WORKSPACE!,
env.MODAL_ENVIRONMENT_WEB_SUFFIX
);
await client.deleteProviderImage({ providerImageId: result.replacedImageId! });
} catch (e) {
logger.warn("repo_image.delete_old_failed", {
Expand Down Expand Up @@ -261,7 +265,11 @@ async function handleTriggerBuild(
}

// Trigger build on Modal
const client = createModalClient(env.MODAL_API_SECRET, env.MODAL_WORKSPACE);
const client = createModalClient(
env.MODAL_API_SECRET,
env.MODAL_WORKSPACE,
env.MODAL_ENVIRONMENT_WEB_SUFFIX
);
await client.buildRepoImage(
{
repoOwner: owner,
Expand Down
41 changes: 39 additions & 2 deletions packages/control-plane/src/sandbox/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { describe, expect, it } from "vitest";
import { buildModalSandboxDashboardUrl } from "./client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildModalSandboxDashboardUrl,
buildModalWorkspaceSlug,
createModalClient,
} from "./client";

describe("buildModalWorkspaceSlug", () => {
it("uses the raw workspace when the Modal environment has no web suffix", () => {
expect(buildModalWorkspaceSlug("acme")).toBe("acme");
expect(buildModalWorkspaceSlug("acme", "")).toBe("acme");
});

it("appends the Modal environment web suffix for endpoint URLs", () => {
expect(buildModalWorkspaceSlug("acme", "prod-web")).toBe("acme-prod-web");
});
});

describe("buildModalSandboxDashboardUrl", () => {
it("builds a Modal dashboard URL for a sandbox object", () => {
Expand Down Expand Up @@ -52,3 +67,25 @@ describe("buildModalSandboxDashboardUrl", () => {
).toBeNull();
});
});

describe("ModalClient", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("uses the Modal environment web suffix in endpoint URLs", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ success: true, data: { status: "ok", service: "modal" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);

const client = createModalClient("secret", "acme", "prod-web");
await client.health();

expect(fetchMock).toHaveBeenCalledWith(
"https://acme-prod-web--open-inspect-api-health.modal.run"
);
});
});
36 changes: 25 additions & 11 deletions packages/control-plane/src/sandbox/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,33 @@ const log = createLogger("modal-client");
const MODAL_APP_NAME = "open-inspect";

// Modal's default environment name; unrelated to the git branch named "main".
const DEFAULT_MODAL_DASHBOARD_ENVIRONMENT = "main";
const DEFAULT_MODAL_ENVIRONMENT = "main";

/**
* Construct the Modal base URL from workspace name.
* Modal endpoint URLs follow the pattern: https://{workspace}--{app-name}
* Build the Modal endpoint workspace slug from the raw workspace and environment web suffix.
*/
function getModalBaseUrl(workspace: string): string {
return `https://${workspace}--${MODAL_APP_NAME}`;
export function buildModalWorkspaceSlug(workspace: string, environmentWebSuffix = ""): string {
return environmentWebSuffix === "" ? workspace : `${workspace}-${environmentWebSuffix}`;
}

/**
* Construct the Modal base URL from workspace and environment web suffix.
*/
function getModalBaseUrl(workspace: string, environmentWebSuffix?: string): string {
return `https://${buildModalWorkspaceSlug(workspace, environmentWebSuffix)}--${MODAL_APP_NAME}`;
}

/**
* Build a Modal dashboard link for a sandbox object.
*/
export function buildModalSandboxDashboardUrl(params: {
workspace: string | undefined;
environment?: string | undefined;
providerObjectId: string | null | undefined;
}): string | null {
if (!params.workspace || !params.providerObjectId) return null;
const workspace = encodeURIComponent(params.workspace);
const environment = encodeURIComponent(params.environment || DEFAULT_MODAL_DASHBOARD_ENVIRONMENT);
const environment = encodeURIComponent(params.environment || DEFAULT_MODAL_ENVIRONMENT);
const providerObjectId = encodeURIComponent(params.providerObjectId);
return `https://modal.com/apps/${workspace}/${environment}/deployed/${MODAL_APP_NAME}?activeTab=sandboxes&sandboxId=${providerObjectId}`;
}
Expand Down Expand Up @@ -182,15 +191,15 @@ export class ModalClient {
private deleteProviderImageUrl: string;
private secret: string;

constructor(secret: string, workspace: string) {
constructor(secret: string, workspace: string, environmentWebSuffix?: string) {
if (!secret) {
throw new Error("ModalClient requires MODAL_API_SECRET for authentication");
}
if (!workspace) {
throw new Error("ModalClient requires MODAL_WORKSPACE for URL construction");
}
this.secret = secret;
const baseUrl = getModalBaseUrl(workspace);
const baseUrl = getModalBaseUrl(workspace, environmentWebSuffix);
this.createSandboxUrl = `${baseUrl}-api-create-sandbox.modal.run`;
this.warmSandboxUrl = `${baseUrl}-api-warm-sandbox.modal.run`;
this.healthUrl = `${baseUrl}-api-health.modal.run`;
Expand Down Expand Up @@ -657,16 +666,21 @@ export class ModalClient {
* The caller is responsible for managing the client lifecycle.
*
* @param secret - The MODAL_API_SECRET for authentication
* @param workspace - The Modal workspace name (used in endpoint URLs)
* @param workspace - The Modal workspace name
* @param environmentWebSuffix - The Modal environment web suffix used in endpoint URLs
* @returns A new ModalClient instance
* @throws Error if secret or workspace is not provided
*/
export function createModalClient(secret: string, workspace: string): ModalClient {
export function createModalClient(
secret: string,
workspace: string,
environmentWebSuffix?: string
): ModalClient {
if (!secret) {
throw new Error("MODAL_API_SECRET is required to create ModalClient");
}
if (!workspace) {
throw new Error("MODAL_WORKSPACE is required to create ModalClient");
}
return new ModalClient(secret, workspace);
return new ModalClient(secret, workspace, environmentWebSuffix);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
*
* @example
* ```typescript
* const client = createModalClient(secret, workspace);
* const client = createModalClient(secret, workspace, environmentWebSuffix);
* const provider = new ModalSandboxProvider(client);
*
* try {
Expand Down
4 changes: 3 additions & 1 deletion packages/control-plane/src/session/durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,8 @@ export class SessionDO extends DurableObject<Env> {

const modalClient = createModalClient(
this.env.MODAL_API_SECRET,
this.env.MODAL_WORKSPACE
this.env.MODAL_WORKSPACE,
this.env.MODAL_ENVIRONMENT_WEB_SUFFIX
);
return createModalProvider(modalClient);
})();
Expand Down Expand Up @@ -1723,6 +1724,7 @@ export class SessionDO extends DurableObject<Env> {
if (resolveSandboxBackendName(this.env.SANDBOX_PROVIDER) !== "modal") return null;
return buildModalSandboxDashboardUrl({
workspace: this.env.MODAL_WORKSPACE,
environment: this.env.MODAL_ENVIRONMENT,
providerObjectId,
});
}
Expand Down
Loading
Loading