fix(apps.create): clone via HTTPS+bot-PAT; activate bot users on creation

Coolify was failing all Gitea clones with "Permission denied (publickey)"
because the helper container's SSH hits git.vibnai.com:22 (Ubuntu host
sshd, which doesn't know Gitea keys), while Gitea's builtin SSH is on
host port 22222 (not publicly reachable).

Rather than fight the SSH topology, switch every Vibn-provisioned app
to clone over HTTPS with the workspace bot's PAT embedded in the URL.
The PAT is already stored encrypted per workspace and scoped to that
org, so this gives equivalent isolation with zero SSH dependency.

Changes:
- lib/naming.ts: add giteaHttpsUrl() + redactGiteaHttpsUrl(); mark
  giteaSshUrl() as deprecated-for-deploys with a comment.
- lib/coolify.ts: extend CreatePublicAppOpts with install/build/start
  commands, base_directory, dockerfile_location, docker_compose_location,
  manual_webhook_secret_gitea so it's at parity with the SSH variant.
- app/api/mcp/route.ts:
  - apps.create now uses createPublicApp(giteaHttpsUrl(...)) and pulls
    the bot PAT via getWorkspaceBotCredentials(). No more private-
    deploy-key path for new apps.
  - apps.update adds git_commit_sha + docker_compose_location to the
    whitelist.
  - New apps.rewire_git tool: re-points an app's git_repository at the
    canonical HTTPS+PAT URL. Unblocks older apps stuck on SSH URLs
    and provides a path for PAT rotation without rebuilding the app.
- lib/gitea.ts: createUser() now issues an immediate PATCH to set
  active: true. Gitea's admin-create endpoint creates users as inactive
  by default, and inactive users fail permission checks even though
  they're org members. GiteaUser gains optional `active` field.
- scripts/activate-workspace-bots.ts: idempotent backfill that flips
  active=true for any existing workspace bot that was created before
  this fix. Safe to re-run.
- AI_CAPABILITIES.md: document apps.rewire_git; clarify apps.create
  uses HTTPS+PAT (no SSH).

Already unblocked in prod for the mark workspace:
- vibn-bot-mark activated.
- twenty-crm's git_repository PATCHed to HTTPS+PAT form; git clone
  now succeeds (remaining unrelated error: docker-compose file path).

Made-with: Cursor
This commit is contained in:
2026-04-23 12:21:00 -07:00
parent 3192e0f7b9
commit fcd5d03894
5 changed files with 244 additions and 12 deletions

View File

@@ -31,7 +31,7 @@ import {
upsertApplicationEnv, upsertApplicationEnv,
deleteApplicationEnv, deleteApplicationEnv,
// Phase 4 ── create/update/delete + domains + databases + services // Phase 4 ── create/update/delete + domains + databases + services
createPrivateDeployKeyApp, createPublicApp,
updateApplication, updateApplication,
deleteApplication, deleteApplication,
setApplicationDomains, setApplicationDomains,
@@ -49,7 +49,7 @@ import {
import { query } from '@/lib/db-postgres'; import { query } from '@/lib/db-postgres';
import { getRepo } from '@/lib/gitea'; import { getRepo } from '@/lib/gitea';
import { import {
giteaSshUrl, giteaHttpsUrl,
isDomainUnderWorkspace, isDomainUnderWorkspace,
slugify, slugify,
toDomainsString, toDomainsString,
@@ -85,6 +85,7 @@ export async function GET() {
'apps.get', 'apps.get',
'apps.create', 'apps.create',
'apps.update', 'apps.update',
'apps.rewire_git',
'apps.delete', 'apps.delete',
'apps.deploy', 'apps.deploy',
'apps.deployments', 'apps.deployments',
@@ -171,6 +172,8 @@ export async function POST(request: Request) {
return await toolAppsCreate(principal, params); return await toolAppsCreate(principal, params);
case 'apps.update': case 'apps.update':
return await toolAppsUpdate(principal, params); return await toolAppsUpdate(principal, params);
case 'apps.rewire_git':
return await toolAppsRewireGit(principal, params);
case 'apps.delete': case 'apps.delete':
return await toolAppsDelete(principal, params); return await toolAppsDelete(principal, params);
case 'apps.domains.list': case 'apps.domains.list':
@@ -449,9 +452,21 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
async function toolAppsCreate(principal: Principal, params: Record<string, any>) { async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace; const ws = principal.workspace;
if (!ws.coolify_project_uuid || !ws.coolify_private_key_uuid || !ws.gitea_org) { if (!ws.coolify_project_uuid || !ws.gitea_org) {
return NextResponse.json( return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' }, { error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' },
{ status: 503 }
);
}
// We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's
// builtin SSH is on an internal-only port and port 22 hits the host's
// OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and
// the PAT is scoped to the org via team membership.
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
{ error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' },
{ status: 503 } { status: 503 }
); );
} }
@@ -484,13 +499,12 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
); );
} }
const created = await createPrivateDeployKeyApp({ const created = await createPublicApp({
projectUuid: ws.coolify_project_uuid, projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined, serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name, environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined, destinationUuid: ws.coolify_destination_uuid ?? undefined,
privateKeyUuid: ws.coolify_private_key_uuid, gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
gitRepository: giteaSshUrl(repoOrg, repoName),
gitBranch: String(params.branch ?? repo.default_branch ?? 'main'), gitBranch: String(params.branch ?? repo.default_branch ?? 'main'),
portsExposes: String(params.ports ?? '3000'), portsExposes: String(params.ports ?? '3000'),
buildPack: (params.buildPack as any) ?? 'nixpacks', buildPack: (params.buildPack as any) ?? 'nixpacks',
@@ -499,6 +513,13 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
isAutoDeployEnabled: true, isAutoDeployEnabled: true,
isForceHttpsEnabled: true, isForceHttpsEnabled: true,
instantDeploy: false, instantDeploy: false,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
dockerfileLocation: params.dockerfileLocation
? String(params.dockerfileLocation)
: undefined,
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
}); });
// Attach envs // Attach envs
@@ -543,9 +564,9 @@ async function toolAppsUpdate(principal: Principal, params: Record<string, any>)
await getApplicationInProject(appUuid, projectUuid); await getApplicationInProject(appUuid, projectUuid);
const allowed = new Set([ const allowed = new Set([
'name', 'description', 'git_branch', 'build_pack', 'ports_exposes', 'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes',
'install_command', 'build_command', 'start_command', 'install_command', 'build_command', 'start_command',
'base_directory', 'dockerfile_location', 'base_directory', 'dockerfile_location', 'docker_compose_location',
'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image',
]); ]);
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
@@ -559,6 +580,70 @@ async function toolAppsUpdate(principal: Principal, params: Record<string, any>)
return NextResponse.json({ result: { ok: true, uuid: appUuid } }); return NextResponse.json({ result: { ok: true, uuid: appUuid } });
} }
/**
* Re-point an app's git_repository at the workspace's canonical
* HTTPS+PAT clone URL. Useful to recover older apps that were created
* with SSH URLs (which don't work on this Gitea topology), or to
* rotate the bot PAT embedded in the URL after a credential cycle.
* The repo name is inferred from the current URL unless `repo` is
* passed explicitly (in `owner/name` form).
*/
async function toolAppsRewireGit(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ws = principal.workspace;
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
{ error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' },
{ status: 503 }
);
}
const app = await getApplicationInProject(appUuid, projectUuid);
let repoOrg: string;
let repoName: string;
if (params.repo) {
const parts = String(params.repo).replace(/\.git$/, '').split('/');
if (parts.length !== 2) {
return NextResponse.json({ error: 'Param "repo" must be "owner/name"' }, { status: 400 });
}
[repoOrg, repoName] = parts;
} else {
const m = (app.git_repository ?? '').match(
/(?:git@[^:]+:|https?:\/\/(?:[^/]+@)?[^/]+\/)([^/]+)\/([^/.]+)(?:\.git)?$/
);
if (!m) {
return NextResponse.json(
{ error: 'Could not infer repo from current git_repository; pass repo="owner/name"' },
{ status: 400 }
);
}
[, repoOrg, repoName] = m;
}
if (repoOrg !== ws.gitea_org) {
return NextResponse.json(
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
{ status: 403 }
);
}
const newUrl = giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token);
await updateApplication(appUuid, { git_repository: newUrl });
return NextResponse.json({
result: {
ok: true,
uuid: appUuid,
repo: `${repoOrg}/${repoName}`,
gitUrlScheme: 'https+pat',
},
});
}
async function toolAppsDelete(principal: Principal, params: Record<string, any>) { async function toolAppsDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal); const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid; if (projectUuid instanceof NextResponse) return projectUuid;

View File

@@ -402,7 +402,7 @@ export async function createPrivateDeployKeyApp(
export interface CreatePublicAppOpts { export interface CreatePublicAppOpts {
projectUuid: string; projectUuid: string;
gitRepository: string; // https URL gitRepository: string; // https URL (can embed basic-auth creds for private repos)
gitBranch?: string; gitBranch?: string;
portsExposes: string; portsExposes: string;
serverUuid?: string; serverUuid?: string;
@@ -415,6 +415,13 @@ export interface CreatePublicAppOpts {
isAutoDeployEnabled?: boolean; isAutoDeployEnabled?: boolean;
isForceHttpsEnabled?: boolean; isForceHttpsEnabled?: boolean;
instantDeploy?: boolean; instantDeploy?: boolean;
installCommand?: string;
buildCommand?: string;
startCommand?: string;
baseDirectory?: string;
dockerfileLocation?: string;
dockerComposeLocation?: string;
manualWebhookSecretGitea?: string;
} }
export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> { export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid: string }> {
@@ -433,6 +440,13 @@ export async function createPublicApp(opts: CreatePublicAppOpts): Promise<{ uuid
is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true, is_auto_deploy_enabled: opts.isAutoDeployEnabled ?? true,
is_force_https_enabled: opts.isForceHttpsEnabled ?? true, is_force_https_enabled: opts.isForceHttpsEnabled ?? true,
instant_deploy: opts.instantDeploy ?? true, instant_deploy: opts.instantDeploy ?? true,
install_command: opts.installCommand,
build_command: opts.buildCommand,
start_command: opts.startCommand,
base_directory: opts.baseDirectory,
dockerfile_location: opts.dockerfileLocation,
docker_compose_location: opts.dockerComposeLocation,
manual_webhook_secret_gitea: opts.manualWebhookSecretGitea,
}); });
return coolifyFetch('/applications/public', { return coolifyFetch('/applications/public', {
method: 'POST', method: 'POST',

View File

@@ -155,6 +155,7 @@ export interface GiteaUser {
login: string; login: string;
full_name?: string; full_name?: string;
email?: string; email?: string;
active?: boolean;
} }
/** /**
@@ -168,7 +169,7 @@ export async function createUser(opts: {
password: string; password: string;
fullName?: string; fullName?: string;
}): Promise<GiteaUser> { }): Promise<GiteaUser> {
return giteaFetch(`/admin/users`, { const created = await giteaFetch(`/admin/users`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username: opts.username, username: opts.username,
@@ -180,6 +181,26 @@ export async function createUser(opts: {
source_id: 0, source_id: 0,
}), }),
}); });
// Gitea's admin-create endpoint returns users with `active=false` by
// default (a quirk of the admin API — UI-created users skip email
// verification but API-created ones don't). Inactive users fail
// permission checks and cannot clone private repos, so we flip the
// flag immediately via a PATCH. Idempotent: a second call is a noop.
try {
await giteaFetch(`/admin/users/${opts.username}`, {
method: 'PATCH',
body: JSON.stringify({
source_id: 0,
login_name: opts.username,
active: true,
}),
});
(created as GiteaUser).active = true;
} catch (err) {
console.warn('[gitea] failed to activate bot user', opts.username, err);
}
return created;
} }
/** /**

View File

@@ -60,10 +60,49 @@ export function isDomainUnderWorkspace(fqdn: string, workspaceSlug: string): boo
/** /**
* Build a Gitea SSH clone URL for a repo in a workspace's org. * Build a Gitea SSH clone URL for a repo in a workspace's org.
* Matches what Coolify's `private-deploy-key` flow expects. *
* NOTE: As of 2026-04 this is deprecated for Coolify-driven deploys on
* vibnai.com — Gitea's builtin SSH is bound to host port 22222 which is
* not publicly reachable, and the default port 22 hits Ubuntu's host
* sshd which doesn't know about Gitea keys. Use {@link giteaHttpsUrl}
* instead and embed the workspace bot's PAT. Kept for read-only places
* (e.g. UI display) that want the canonical "clone with SSH" form.
*/ */
export function giteaSshUrl(org: string, repo: string, giteaHost = 'git.vibnai.com'): string { export function giteaSshUrl(org: string, repo: string, giteaHost = 'git.vibnai.com'): string {
return `git@${giteaHost}:${org}/${repo}.git`; return `git@${giteaHost}:${org}/${repo}.git`;
} }
/**
* Build a Gitea HTTPS clone URL with basic-auth credentials embedded.
*
* https://{username}:{token}@{host}/{org}/{repo}.git
*
* This is what we pass to Coolify's `git_repository` field for every
* Vibn-provisioned app. Works regardless of SSH topology and scopes
* access to whatever the bot user can see. The token is usually the
* per-workspace Gitea bot PAT.
*
* We URL-encode the username and token so PATs with special chars
* (`:`, `/`, `@`, `#`, `?`, etc.) don't break URL parsing.
*/
export function giteaHttpsUrl(
org: string,
repo: string,
username: string,
token: string,
giteaHost = 'git.vibnai.com'
): string {
const u = encodeURIComponent(username);
const t = encodeURIComponent(token);
return `https://${u}:${t}@${giteaHost}/${org}/${repo}.git`;
}
/**
* Redact the credentials from a Gitea HTTPS URL for safe logging /
* display. Leaves the repo path intact. No-op for non-HTTPS URLs.
*/
export function redactGiteaHttpsUrl(url: string): string {
return url.replace(/^(https?:\/\/)[^@]+@/, '$1***:***@');
}
export { VIBN_BASE_DOMAIN }; export { VIBN_BASE_DOMAIN };

View File

@@ -0,0 +1,73 @@
/**
* Backfill: activate any Gitea bot users that were created before the
* provisioner started flipping `active: true` automatically.
*
* Safe to re-run — bots that are already active stay active.
*
* Usage:
* npx dotenv-cli -e .env.local -- npx tsx scripts/activate-workspace-bots.ts
*/
import { query } from '../lib/db-postgres';
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
if (!GITEA_API_TOKEN) {
console.error('GITEA_API_TOKEN not set — cannot proceed');
process.exit(1);
}
async function gitea(path: string, init?: RequestInit) {
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `token ${GITEA_API_TOKEN}`,
...(init?.headers ?? {}),
},
});
if (!res.ok && res.status !== 204) {
throw new Error(`gitea ${path}${res.status}: ${await res.text()}`);
}
if (res.status === 204) return null;
return res.json();
}
async function main() {
const rows = await query<{ slug: string; gitea_bot_username: string }>(
`SELECT slug, gitea_bot_username
FROM vibn_workspaces
WHERE gitea_bot_username IS NOT NULL`
);
console.log(`Checking ${rows.length} workspace bot(s)…`);
let touched = 0;
for (const { slug, gitea_bot_username: bot } of rows) {
try {
const u = (await gitea(`/users/${bot}`)) as { active?: boolean } | null;
if (!u) {
console.warn(` [${slug}] bot ${bot} not found in Gitea`);
continue;
}
if (u.active) {
console.log(` [${slug}] ${bot} already active`);
continue;
}
await gitea(`/admin/users/${bot}`, {
method: 'PATCH',
body: JSON.stringify({ source_id: 0, login_name: bot, active: true }),
});
console.log(` [${slug}] activated ${bot}`);
touched++;
} catch (err) {
console.error(` [${slug}] error:`, err instanceof Error ? err.message : err);
}
}
console.log(`Done — activated ${touched} bot(s).`);
}
main().catch((err) => {
console.error('FAILED:', err);
process.exit(1);
});