feat(git): thin project onboarding, provider integration, and AgentOS repository access

Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
This commit is contained in:
-Puter
2026-07-31 14:36:56 +05:30
parent 88f53f550d
commit c644ec8d01
36 changed files with 4821 additions and 119 deletions

View File

@@ -0,0 +1,465 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import {
FileText,
FolderGit2,
GitBranch,
LoaderCircle,
Server,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const createProjectFromRepositoryRef = makeFunctionReference<
"mutation",
{ gitRepositoryId: Id<"gitRepositories">; name: string },
{ projectId: Id<"projects"> }
>("gitProvisioning:createProjectFromRepository");
const LoadingState = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
</main>
);
const EmptyProjects = () => (
<div className="mt-12 text-center">
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
Connect your first project
</h1>
<p className="mt-2 text-sm text-[#68665e]">
Choose a Git provider below to get started.
</p>
</div>
);
const ProjectCard = ({
instructions,
name,
projectId,
sourceUrl,
}: {
instructions: string;
name: string;
projectId: string;
sourceUrl: string;
}) => (
<Link
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
to={`/?project=${projectId}`}
>
<div className="flex items-center gap-2">
<FolderGit2 className="size-4 text-[#747168]" />
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
</div>
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
{instructions ? (
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
<FileText className="size-3" />
<span className="truncate">Context configured</span>
</div>
) : null}
</Link>
);
const ContextEditor = ({
projectId,
}: {
readonly projectId: Id<"projects">;
}) => {
const instructions = useQuery(api.projects.getInstructions, {
projectId,
});
const updateInstructions = useMutation(api.projects.updateInstructions);
const [draft, setDraft] = useState<string>();
const [saving, setSaving] = useState(false);
const currentDraft = draft ?? instructions ?? "";
const dirty = draft !== undefined && draft !== (instructions ?? "");
const save = async () => {
if (!dirty || saving) {
return;
}
setSaving(true);
try {
await updateInstructions({
instructions: draft ?? "",
projectId,
});
setDraft(undefined);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3">
<label
className="text-xs font-medium text-[#20201d]"
htmlFor="context-textarea"
>
Context
</label>
<textarea
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
id="context-textarea"
onChange={(event) => setDraft(event.target.value)}
placeholder="Project-specific instructions for agents..."
value={currentDraft}
/>
{dirty ? (
<Button
className="mt-1 h-8 text-xs"
disabled={saving}
onClick={save}
size="sm"
type="button"
variant="outline"
>
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
{saving ? "Saving" : "Save context"}
</Button>
) : null}
</div>
);
};
const PuterConnectForm = ({
onConnected,
}: {
readonly onConnected: () => void;
}) => {
const connectGitea = useAction(connectGiteaRef);
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!token.trim() || pending) {
return;
}
setPending(true);
setError(undefined);
try {
await connectGitea({
token: token.trim(),
username: username.trim() || undefined,
});
setToken("");
setUsername("");
onConnected();
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : String(caughtError)
);
} finally {
setPending(false);
}
};
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
<p className="text-xs text-[#68665e]">
Connect your Puter Git personal access token. The Puter Git instance is
at <code className="text-[#20201d]">git.openputer.com</code>.
</p>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setUsername(event.target.value)}
placeholder="Puter username (optional)"
value={username}
/>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
required
type="password"
value={token}
/>
{error ? <p className="text-xs text-red-700">{error}</p> : null}
<Button className="h-10 w-full" disabled={pending} type="submit">
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
{pending ? "Connecting" : "Connect Puter Git"}
</Button>
</form>
);
};
const RepositorySelector = ({
onProjectCreated,
}: {
readonly onProjectCreated: (projectId: string) => void;
}) => {
const repositories = useQuery(
makeFunctionReference<
"query",
Record<string, never>,
readonly {
cloneUrl: string;
defaultBranch: string;
fullName: string;
id: string;
name: string;
owner: string;
privacy: "public" | "private";
provider: "github" | "gitea";
webUrl: string;
}[]
>("gitProvisioning:listRepositories"),
{}
);
const createProject = useMutation(createProjectFromRepositoryRef);
const [pending, setPending] = useState<string>();
const create = async (repoId: string, name: string) => {
setPending(repoId);
try {
const result = await createProject({
gitRepositoryId: repoId as Id<"gitRepositories">,
name,
});
onProjectCreated(String(result.projectId));
} finally {
setPending(undefined);
}
};
if (repositories === undefined) {
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
}
if (repositories.length === 0) {
return (
<p className="text-xs text-[#747168]">
No repositories found. Create one on your provider first, or import a
public repository below.
</p>
);
}
return (
<div className="mt-3 space-y-2">
{repositories.map((repo) => (
<button
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
key={repo.id}
onClick={() => create(repo.id, repo.name)}
type="button"
>
<div>
<p className="text-sm font-medium text-[#20201d]">
{repo.fullName}
</p>
<p className="text-xs text-[#747168]">
{repo.provider} - {repo.defaultBranch}
</p>
</div>
{pending === repo.id ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
))}
</div>
);
};
const GitHubConnectButton = () => {
const [pending, setPending] = useState(false);
const linkGithub = async () => {
setPending(true);
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
});
} finally {
setPending(false);
}
};
return (
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={linkGithub}
type="button"
>
<GitBranch className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
</div>
{pending ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
);
};
const ProviderSection = () => {
const [showPuterForm, setShowPuterForm] = useState(false);
return (
<section className="mt-10">
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
<p className="mt-1 text-xs text-[#68665e]">
Connect a repository to create a Project.
</p>
<div className="mt-4 space-y-3">
<GitHubConnectButton />
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={() => setShowPuterForm((v) => !v)}
type="button"
>
<Server className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
<p className="text-xs text-[#747168]">
Connect with a personal access token
</p>
</div>
</button>
</div>
{showPuterForm ? (
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
) : null}
</section>
);
};
const RepositorySection = ({
onProjectCreated,
}: {
readonly onProjectCreated: (projectId: string) => void;
}) => (
<section className="mt-6">
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
<p className="mt-1 text-xs text-[#68665e]">
Select a repository to create a Project.
</p>
<RepositorySelector onProjectCreated={onProjectCreated} />
</section>
);
export const ProjectsPage = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const connectGithubAction = useAction(connectGithubRef);
const [resumeError, setResumeError] = useState<string>();
// Handle GitHub OAuth callback resume
useEffect(() => {
const resume = searchParams.get("resume");
if (resume !== "github") {
return;
}
void (async () => {
try {
await connectGithubAction({});
setSearchParams({}, { replace: true });
} catch (caughtError) {
setResumeError(
caughtError instanceof Error
? caughtError.message
: "GitHub connection failed"
);
}
})();
}, [searchParams, setSearchParams, connectGithubAction]);
if (projects === undefined) {
return <LoadingState />;
}
const hasProjects = projects.length > 0;
const handleProjectCreated = (projectId: string) => {
void navigate(`/?project=${projectId}`, { replace: true });
};
return (
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
<div className="mx-auto max-w-2xl">
<header className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Projects</h1>
{hasProjects ? (
<Button
className="h-9"
onClick={() => navigate("/")}
size="sm"
variant="outline"
>
Home
</Button>
) : null}
</header>
{resumeError ? (
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
{resumeError}
</p>
) : null}
{hasProjects ? null : <EmptyProjects />}
{hasProjects ? (
<section className="mt-6 grid gap-3">
{projects.map((project) => (
<div key={project.id}>
<ProjectCard
instructions={
project.contextDocuments.find(
(doc) => doc.kind === "readme"
)?.content ?? ""
}
name={project.name}
projectId={project.id}
sourceUrl={project.sources[0]?.url ?? ""}
/>
<ContextEditor
projectId={project.id as unknown as Id<"projects">}
/>
</div>
))}
</section>
) : null}
<RepositorySection onProjectCreated={handleProjectCreated} />
<ProviderSection />
<section className="mt-8">
<p className="text-xs text-[#68665e]">
Or import a public repository:
</p>
<Link
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
to="/?import=public"
>
Import public Git URL
</Link>
</section>
</div>
</main>
);
};

View File

@@ -11,7 +11,6 @@ export const ProjectSettingsPanel = ({
readonly onClose: () => void; readonly onClose: () => void;
readonly workspace: WorkspaceState; readonly workspace: WorkspaceState;
}) => { }) => {
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const handleClearOperationError = () => workspace.clearOperationError(); const handleClearOperationError = () => workspace.clearOperationError();
@@ -59,13 +58,7 @@ export const ProjectSettingsPanel = ({
</div> </div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4"> <div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input <input
aria-label="Gitea server URL" aria-label="Puter Git username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setServerUrl(event.target.value)}
value={serverUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs" className="h-9 w-full border px-2 text-xs"
onChange={(event) => setUsername(event.target.value)} onChange={(event) => setUsername(event.target.value)}
placeholder="Username (optional)" placeholder="Username (optional)"
@@ -85,7 +78,6 @@ export const ProjectSettingsPanel = ({
size="sm" size="sm"
onClick={() => { onClick={() => {
void workspace.connectGitea({ void workspace.connectGitea({
serverUrl,
token, token,
username: username || undefined, username: username || undefined,
}); });

View File

@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react"; import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server"; import { makeFunctionReference } from "convex/server";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent"; import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization"; import { usePersonalOrganization } from "@/hooks/use-personal-organization";
@@ -96,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
>("gitConnectionData:getForProject"); >("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference< const connectGiteaRef = makeFunctionReference<
"action", "action",
{ serverUrl: string; token: string; username?: string }, { token: string; username?: string },
{ connectionId: Id<"gitConnections"> } { connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea"); >("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference< const connectGithubRef = makeFunctionReference<
@@ -118,6 +119,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
); );
const importPublicGit = useAction(api.projects.importPublicGit); const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization); const agent = useOrganizationChatAgent(organization);
const [searchParams] = useSearchParams();
const [selectedProjectId, setSelectedProjectId] = const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null); useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState(""); const [repository, setRepository] = useState("");
@@ -125,11 +127,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>(); const [operationError, setOperationError] = useState<Error>();
const projectParam = searchParams.get("project");
const paramProject = projectParam
? (projectParam as unknown as Id<"projects">)
: null;
const selectedProjectStillExists = projects?.some( const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string) (project) =>
project.id === (selectedProjectId as unknown as string) ||
project.id === (paramProject as unknown as string)
); );
const activeProjectId = selectedProjectStillExists const activeProjectId = selectedProjectStillExists
? selectedProjectId ? (selectedProjectId ?? paramProject)
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null); : ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery( const works = useQuery(
workListRef, workListRef,
@@ -202,11 +210,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
}); });
}; };
const connectGitea = (input: { const connectGitea = (input: { token: string; username?: string }) =>
serverUrl: string;
token: string;
username?: string;
}) =>
runOperation(async () => { runOperation(async () => {
const result = await connectGiteaAction(input); const result = await connectGiteaAction(input);
await attachConnection(result.connectionId); await attachConnection(result.connectionId);
@@ -228,8 +232,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
designVersion: number designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }), ) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () => authorizeGithub: () =>
authClient.signIn.social({ authClient.linkSocial({
callbackURL: window.location.href, callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github", provider: "github",
}), }),
cancelExecution: (runId: Id<"workRuns">) => cancelExecution: (runId: Id<"workRuns">) =>

View File

@@ -95,7 +95,6 @@ export interface WorkspaceState {
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>; readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void; readonly clearOperationError: () => void;
readonly connectGitea: (input: { readonly connectGitea: (input: {
readonly serverUrl: string;
readonly token: string; readonly token: string;
readonly username?: string; readonly username?: string;
}) => Promise<void>; }) => Promise<void>;

View File

@@ -6,5 +6,8 @@ export default [
route("login", "./routes/auth/login/page.tsx"), route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"), route("signup", "./routes/auth/signup/page.tsx"),
]), ]),
layout("./routes/app/layout.tsx", [index("./routes/app/workspace/page.tsx")]), layout("./routes/app/layout.tsx", [
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig; ] satisfies RouteConfig;

View File

@@ -1,5 +1,9 @@
import { Outlet } from "react-router"; import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { requireAuthToken } from "@/lib/auth.server"; import { requireAuthToken } from "@/lib/auth.server";
import type { Route } from "./+types/layout"; import type { Route } from "./+types/layout";
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request); requireAuthToken(request);
export default function AppLayout() { export default function AppLayout() {
const organization = usePersonalOrganization();
const navigate = useNavigate();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
useEffect(() => {
if (
projects !== undefined &&
projects.length === 0 &&
window.location.pathname === "/" &&
!window.location.search.includes("resume=") &&
!window.location.search.includes("project=")
) {
void navigate("/projects", { replace: true });
}
}, [projects, navigate]);
return <Outlet />; return <Outlet />;
} }

View File

@@ -0,0 +1,5 @@
import { ProjectsPage } from "@/components/projects/projects-page";
export default function ProjectsRoute() {
return <ProjectsPage />;
}

128
docs/git-provider-setup.md Normal file
View File

@@ -0,0 +1,128 @@
# Git Provider Setup
This document covers manual setup for GitHub OAuth, Puter Git (Gitea), webhooks, and Convex environment variables.
> **Never place admin or user tokens in checked-in `.env` files or browser variables.** All secrets must be Convex environment variables set via `npx convex env set`.
## GitHub OAuth Application
1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App.
2. Set the application name (e.g., "Zopu").
3. Set the homepage URL to your `SITE_URL` (e.g., `https://zopu.cheaptricks.puter.wtf`).
4. Set the callback URL to `<SITE_URL>/api/auth/callback/github`.
5. Generate a client secret.
6. Set Convex environment variables:
```
npx convex env set GITHUB_CLIENT_ID <your-client-id>
npx convex env set GITHUB_CLIENT_SECRET <your-client-secret>
```
### Requested scopes
Zopu requests `repo` and `read:org`. The `repo` scope grants access to private repositories. `read:org` allows listing organization repositories.
## GitHub Webhook (manual setup for OAuth-based version)
1. Go to the GitHub repository Settings > Webhooks > Add webhook.
2. Payload URL: `<CONVEX_SITE_URL>/api/git/webhooks/github`
3. Content type: `application/json`
4. Secret: generate a strong random string and set it as:
```
npx convex env set GITHUB_WEBHOOK_SECRET <your-webhook-secret>
```
5. Select individual events:
- Push
- Repository (created, deleted, transferred, renamed, visibility)
- Delete
- Create
- Public
- Fork
6. Do not select "Send me everything."
## Puter Git (Gitea) Admin Token
1. As a Gitea admin, go to Settings > Applications > Generate New Token.
2. Select scopes: `write:admin`, `write:organization`, `write:repository`.
3. Set the token:
```
npx convex env set PUTER_GIT_ADMIN_TOKEN <your-admin-token>
```
This token is used only for platform administration (user creation, organization creation, repository creation, migration). It is never used for end-user operations.
## Puter Git Webhook
After a repository is created or migrated, Zopu ensures a webhook is configured automatically. If manual setup is needed:
1. Go to the Gitea repository Settings > Webhooks > Add Webhook > Gitea.
2. Target URL: `<CONVEX_SITE_URL>/api/git/webhooks/puter`
3. HTTP method: POST
4. Content-Type: `application/json`
5. Secret: generate a strong random string and set it as:
```
npx convex env set GITEA_WEBHOOK_SECRET <your-webhook-secret>
```
6. Trigger on: Push events, Repository events.
## Credential Encryption Key
Generate a 32-byte encryption key for AES-GCM credential encryption:
```bash
openssl rand -base64 32 | base64 | tr -d '\n' | pbcopy
```
Set it as:
```
npx convex env set GIT_CREDENTIAL_ENCRYPTION_KEY <base64url-encoded-32-bytes>
```
The key must be exactly 32 bytes when base64url-decoded.
## Same-Origin Proxy for Better Auth Callbacks
Better Auth requires the callback URL to be on the same origin as `SITE_URL`. If your Convex deployment uses a different origin:
1. Configure a reverse proxy (e.g., Caddy) to forward `/api/auth/*` to the Convex deployment.
2. Set `SITE_URL` to the proxy origin.
3. Set `CONVEX_SITE_URL` to the Convex deployment URL.
## Validation Procedures
### Verify a Puter PAT connection
1. Connect a Puter PAT through the UI.
2. Verify the connection state shows `active`.
3. List private repositories to confirm token validity.
### Verify GitHub OAuth
1. Click "Connect GitHub" in the UI.
2. Complete the GitHub OAuth flow.
3. Verify the connection state shows `active`.
### Reconnection
If a token is expired or revoked:
1. The connection state shows `reauth-required`.
2. Reconnect the provider through the UI.
3. The old connection state is updated to `active`.
4. Projects, repositories, Work, and artifacts are not deleted.
## Environment Variables Summary
| Variable | Required | Description |
| --- | --- | --- |
| `SITE_URL` | Yes | Public-facing URL |
| `CONVEX_SITE_URL` | Yes | Convex deployment URL (may differ from SITE_URL with proxy) |
| `GIT_CREDENTIAL_ENCRYPTION_KEY` | Yes | Base64url-encoded 32-byte AES-GCM key |
| `PUTER_GIT_ADMIN_TOKEN` | Yes | Gitea admin token for provisioning |
| `GITHUB_CLIENT_ID` | For GitHub | OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | For GitHub | OAuth app client secret |
| `GITHUB_WEBHOOK_SECRET` | For GitHub webhooks | HMAC verification secret |
| `GITEA_WEBHOOK_SECRET` | For Puter webhooks | HMAC verification secret |
| `FLUE_DB_TOKEN` | Yes | Private agent backend token |
| `FLUE_URL` | Optional | Private agent backend URL |
| `NATIVE_APP_URL` | Optional | Native app deep-link scheme |

View File

@@ -1,6 +1,53 @@
{ {
"name": "code", "name": "code",
"private": true, "private": true,
"workspaces": {
"packages": [
"apps/web",
"packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "17.4.2",
"zod": "4.4.3",
"lucide-react": "1.27.0",
"next-themes": "0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "2.0.7",
"convex": "1.42.3",
"better-auth": "1.6.15",
"@convex-dev/better-auth": "0.12.5",
"@tanstack/react-form": "1.33.2",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.3.3",
"tailwind-merge": "3.6.0",
"@better-auth/expo": "1.6.15",
"effect": "4.0.0-beta.99",
"typescript": "7.0.2",
"@types/bun": "1.3.14",
"heroui-native": "1.0.6",
"vite": "8.1.5",
"vitest": "4.1.10",
"convex-test": "0.0.54",
"react-native": "0.86.0",
"@types/react": "19.2.17",
"@types/node": "22.20.1",
"hono": "4.12.32",
"valibot": "1.4.2",
"streamdown": "2.5.0",
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/vite": "4.3.3"
}
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vp run -r dev", "dev": "vp run -r dev",
@@ -41,5 +88,10 @@
"vite-plus": "0.2.2", "vite-plus": "0.2.2",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"overrides": {
"react": "19.2.8",
"react-dom": "19.2.8",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "pnpm@11.17.0" "packageManager": "pnpm@11.17.0"
} }

View File

@@ -103,6 +103,8 @@ export const executeAgentOsAttempt = async (
const hostRepository = new HostRepositoryWorkspace(); const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({ const prepared = await hostRepository.prepare({
attemptId: input.attemptId, attemptId: input.attemptId,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
piHomeFiles: makePiHomeFiles({ piHomeFiles: makePiHomeFiles({
api: env.AGENT_MODEL_API, api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY", apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
@@ -112,14 +114,16 @@ export const executeAgentOsAttempt = async (
model: env.AGENT_MODEL_NAME, model: env.AGENT_MODEL_NAME,
provider: env.AGENT_MODEL_PROVIDER, provider: env.AGENT_MODEL_PROVIDER,
}), }),
provider: input.auth.provider,
username: input.auth.username,
}); });
events.push( events.push(
event( event(
1, 1,
"runtime.preparing", "runtime.preparing",
prepared.created prepared.created
? "Isolated Zopu worktree created on the execution host" ? "Repository cloned on the execution host"
: "Isolated Zopu worktree recreated" : "Repository re-cloned"
) )
); );
const mounts = [ const mounts = [
@@ -128,11 +132,6 @@ export const executeAgentOsAttempt = async (
path: "/workspace/repository", path: "/workspace/repository",
readOnly: false, readOnly: false,
}, },
{
hostPath: prepared.sourceRepositoryPath,
path: prepared.sourceRepositoryPath,
readOnly: false,
},
{ {
hostPath: prepared.piHomePath, hostPath: prepared.piHomePath,
path: "/home/zopu", path: "/home/zopu",
@@ -178,9 +177,9 @@ export const executeAgentOsAttempt = async (
const sessionId = `pi-${input.attemptId}`; const sessionId = `pi-${input.attemptId}`;
await vm.openSession({ await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`], additionalDirectories: [`${prepared.checkoutPath}/.git`],
additionalInstructions: additionalInstructions:
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.", "Work only inside /workspace/repository. This is an isolated checkout of the project repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, push, or open a pull request. Implement the requested change and run focused verification.",
agent: "pi", agent: "pi",
cwd: "/workspace/repository", cwd: "/workspace/repository",
env: piSessionEnv(env.AGENT_MODEL_API_KEY), env: piSessionEnv(env.AGENT_MODEL_API_KEY),

View File

@@ -15,6 +15,13 @@ import type { PiHomeFiles } from "@code/primitives";
interface PrepareRepositoryInput { interface PrepareRepositoryInput {
attemptId: string; attemptId: string;
cloneUrl: string;
/** Provider credential for the initial clone, stripped after cloning. */
credential?: string;
/** Provider type: "github" or "gitea". Determines the askpass username. */
provider?: string;
/** Account username for Gitea provider authentication. */
username?: string;
piHomeFiles: PiHomeFiles; piHomeFiles: PiHomeFiles;
} }
@@ -102,27 +109,17 @@ const pathExists = async (target: string): Promise<boolean> => {
export class HostRepositoryWorkspace { export class HostRepositoryWorkspace {
readonly #root: string; readonly #root: string;
readonly #sourceRepositoryPath: string;
readonly #installDependencies: boolean; readonly #installDependencies: boolean;
constructor( constructor(
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces", root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
"/opt/zopu-source",
installDependencies = true installDependencies = true
) { ) {
this.#root = root; this.#root = root;
this.#sourceRepositoryPath = sourceRepositoryPath;
this.#installDependencies = installDependencies; this.#installDependencies = installDependencies;
} }
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> { async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
throw new Error(
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
);
}
const identity = createHash("sha256") const identity = createHash("sha256")
.update(input.attemptId) .update(input.attemptId)
.digest("hex") .digest("hex")
@@ -131,42 +128,57 @@ export class HostRepositoryWorkspace {
const checkoutPath = path.join(workspacePath, "repository"); const checkoutPath = path.join(workspacePath, "repository");
const toolsPath = path.join(workspacePath, "tools"); const toolsPath = path.join(workspacePath, "tools");
const piHomePath = path.join(workspacePath, "home"); const piHomePath = path.join(workspacePath, "home");
const branch = `zopu/attempt-${identity}`;
const created = !(await pathExists(path.join(checkoutPath, ".git"))); const created = !(await pathExists(path.join(checkoutPath, ".git")));
await mkdir(workspacePath, { recursive: true }); await mkdir(workspacePath, { recursive: true });
if (!created) {
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"remove",
"--force",
checkoutPath,
]),
"Existing worktree removal"
);
}
await rm(checkoutPath, { force: true, recursive: true }); await rm(checkoutPath, { force: true, recursive: true });
requireSuccess(
await runGit(this.#sourceRepositoryPath, [ // Clone the user's repository. When a credential is provided, use a
"worktree", // temporary GIT_ASKPASS helper script so the token never appears in the
"add", // clone URL, git config, or process arguments visible in process listings.
"-B", // The askpass script reads the credential from an env var that is only
branch, // set for this clone command and cleared afterward.
checkoutPath, const cloneEnv: Record<string, string> = {};
"HEAD", if (input.credential) {
]), const askpassPath = path.join(workspacePath, ".git-askpass");
"Zopu worktree creation" // GitHub uses x-access-token as the username for OAuth tokens;
); // Gitea uses the account's actual username.
const gitUsername =
input.provider === "gitea"
? (input.username ?? "git")
: "x-access-token";
// eslint-disable-next-line no-template-curly-in-string -- shell variable, not JS template
const tokenRef = "${ZOPU_GIT_TOKEN}";
await writeFile(
askpassPath,
`#!/bin/sh\ncase "$1" in\nUsername*) echo "${gitUsername}";;\nPassword*) echo "${tokenRef}";;\nesac\n`
);
await chmod(askpassPath, 0o700);
cloneEnv.GIT_ASKPASS = askpassPath;
cloneEnv.ZOPU_GIT_TOKEN = input.credential;
}
try {
requireSuccess(
await runProcess(
"git",
["clone", "--no-tags", input.cloneUrl, checkoutPath],
workspacePath,
cloneEnv
),
"Repository clone"
);
} finally {
// The credential env var is never written to disk or git config.
// The askpass script is cleaned up with the workspace.
cloneEnv.ZOPU_GIT_TOKEN = "";
delete process.env.ZOPU_GIT_TOKEN;
}
const bunExecutable = const bunExecutable =
process.env.BUN_EXECUTABLE ?? process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim(); execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
if (await pathExists(sourceEnvPath)) {
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
}
if (this.#installDependencies) { if (this.#installDependencies) {
requireSuccess( requireSuccess(
await runProcess( await runProcess(
@@ -204,7 +216,7 @@ export class HostRepositoryWorkspace {
checkoutPath, checkoutPath,
created, created,
piHomePath, piHomePath,
sourceRepositoryPath: this.#sourceRepositoryPath, sourceRepositoryPath: checkoutPath,
toolsPath, toolsPath,
}; };
} }

View File

@@ -37,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
github: { github: {
clientId: env.GITHUB_CLIENT_ID, clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET, clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["repo", "read:org"],
}, },
} }
: {}, : {},

View File

@@ -78,3 +78,24 @@ export const requireProjectMember = async (
const userId = await requireOrganizationMember(ctx, project.organizationId); const userId = await requireOrganizationMember(ctx, project.organizationId);
return { organizationId: project.organizationId, userId }; return { organizationId: project.organizationId, userId };
}; };
/**
* Prove the authenticated user is an owner of the given organization.
* Provisioning functions require owner-level authorization.
*/
export const requireCurrentOrganizationOwner = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireAuthUserId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", userId)
)
.unique();
if (!membership || membership.role !== "owner") {
throw new ConvexError("Organization owner role required");
}
return userId;
};

View File

@@ -10,10 +10,13 @@ const app = defineApp({
FLUE_URL: v.optional(v.string()), FLUE_URL: v.optional(v.string()),
GITEA_TOKEN: v.optional(v.string()), GITEA_TOKEN: v.optional(v.string()),
GITEA_URL: v.optional(v.string()), GITEA_URL: v.optional(v.string()),
GITEA_WEBHOOK_SECRET: v.optional(v.string()),
GITHUB_CLIENT_ID: v.optional(v.string()), GITHUB_CLIENT_ID: v.optional(v.string()),
GITHUB_CLIENT_SECRET: v.optional(v.string()), GITHUB_CLIENT_SECRET: v.optional(v.string()),
GITHUB_WEBHOOK_SECRET: v.optional(v.string()),
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()), GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
NATIVE_APP_URL: v.optional(v.string()), NATIVE_APP_URL: v.optional(v.string()),
PUTER_GIT_ADMIN_TOKEN: v.optional(v.string()),
SITE_URL: v.string(), SITE_URL: v.string(),
}, },
}); });

View File

@@ -12,6 +12,16 @@ const reconcileConversationTurnsRef = makeFunctionReference<
Record<string, never>, Record<string, never>,
{ reconciled: number } { reconciled: number }
>("conversationMessages:reconcileExpiredTurns"); >("conversationMessages:reconcileExpiredTurns");
const reconcileGitConnectionsRef = makeFunctionReference<
"action",
Record<string, never>,
{ checked: number }
>("gitConnectionHealth:reconcileStaleConnections");
const backfillConnectionsRef = makeFunctionReference<
"mutation",
Record<string, never>,
{ migrated: number }
>("gitConnectionData:backfillConnections");
const crons = cronJobs(); const crons = cronJobs();
@@ -25,5 +35,16 @@ crons.interval(
{ seconds: 30 }, { seconds: 30 },
reconcileConversationTurnsRef reconcileConversationTurnsRef
); );
crons.cron(
"reconcile stale git connections",
"0 * * * *",
reconcileGitConnectionsRef
);
// Backfill legacy connections on startup and every 6 hours until all are migrated.
crons.interval(
"backfill legacy git connections",
{ hours: 6 },
backfillConnectionsRef
);
export default crons; export default crons;

View File

@@ -1,21 +1,57 @@
import { providerForHost } from "@code/primitives/git-provider";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz"; import { requireCurrentOrganization, requireProjectMember } from "./authz";
// Provider ("forge") that owns a repository host. A project's source must be const upsertProviderAccount = async (
// served by the same forge whose credentials are attached, so a Gitea token is ctx: MutationCtx,
// never offered to GitHub and vice versa. Unknown hosts return null, leaving args: {
// the caller free to attach without a forge constraint. externalAccountId: string;
export const forgeForHost = (host: string): "github" | "gitea" | null => { externalEmail?: string;
const normalized = host.toLowerCase(); externalUsername: string;
if (normalized === "github.com" || normalized.endsWith(".githost.com")) { organizationId: Id<"organizations">;
return "github"; provider: "github" | "gitea";
serverUrl: string;
userId: string;
} }
if (normalized === "git.openputer.com") { ): Promise<Id<"gitProviderAccounts">> => {
return "gitea"; const existing = await ctx.db
.query("gitProviderAccounts")
.withIndex("by_userId_and_provider_and_serverUrl", (q) =>
q
.eq("userId", args.userId)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
status: "active",
updatedAt: timestamp,
});
return existing._id;
} }
return null;
return await ctx.db.insert("gitProviderAccounts", {
createdAt: timestamp,
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: args.organizationId,
provider: args.provider,
serverUrl: args.serverUrl,
status: "active",
updatedAt: timestamp,
userId: args.userId,
});
}; };
export const persist = internalMutation({ export const persist = internalMutation({
@@ -23,6 +59,10 @@ export const persist = internalMutation({
credentialCiphertext: v.string(), credentialCiphertext: v.string(),
credentialIv: v.string(), credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")), credentialKind: v.union(v.literal("oauth"), v.literal("token")),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
grantedScopesJson: v.optional(v.string()),
provider: v.union(v.literal("github"), v.literal("gitea")), provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(), serverUrl: v.string(),
userId: v.string(), userId: v.string(),
@@ -38,34 +78,54 @@ export const persist = internalMutation({
if (!organization) { if (!organization) {
throw new ConvexError("Organization not found"); throw new ConvexError("Organization not found");
} }
const providerAccountId = await upsertProviderAccount(ctx, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
userId: args.userId,
});
const existing = await ctx.db const existing = await ctx.db
.query("gitConnections") .query("gitConnections")
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) => .withIndex("by_gitProviderAccountId", (q) =>
q q.eq("gitProviderAccountId", providerAccountId)
.eq("organizationId", organization._id)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
) )
.unique(); .unique();
const timestamp = Date.now(); const timestamp = Date.now();
if (existing) { if (existing) {
await ctx.db.patch(existing._id, { await ctx.db.patch(existing._id, {
credentialCiphertext: args.credentialCiphertext, credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv, credentialIv: args.credentialIv,
credentialKind: args.credentialKind, credentialKind: args.credentialKind,
grantedScopesJson: args.grantedScopesJson,
lastError: undefined,
lastVerifiedAt: timestamp,
reauthRequiredAt: undefined,
state: "active",
updatedAt: timestamp, updatedAt: timestamp,
username: args.username, username: args.username,
}); });
return existing._id; return existing._id;
} }
return await ctx.db.insert("gitConnections", { return await ctx.db.insert("gitConnections", {
connectedAt: timestamp, connectedAt: timestamp,
creatorUserId: args.userId,
credentialCiphertext: args.credentialCiphertext, credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv, credentialIv: args.credentialIv,
credentialKind: args.credentialKind, credentialKind: args.credentialKind,
gitProviderAccountId: providerAccountId,
grantedScopesJson: args.grantedScopesJson,
lastVerifiedAt: timestamp,
organizationId: organization._id, organizationId: organization._id,
provider: args.provider, provider: args.provider,
serverUrl: args.serverUrl, serverUrl: args.serverUrl,
state: "active",
updatedAt: timestamp, updatedAt: timestamp,
username: args.username, username: args.username,
}); });
@@ -86,8 +146,10 @@ export const list = query({
connectedAt: connection.connectedAt, connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind, credentialKind: connection.credentialKind,
id: String(connection._id), id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider, provider: connection.provider,
serverUrl: connection.serverUrl, serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username, username: connection.username,
})); }));
}, },
@@ -106,8 +168,10 @@ export const getForProject = query({
connectedAt: connection.connectedAt, connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind, credentialKind: connection.credentialKind,
id: String(connection._id), id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider, provider: connection.provider,
serverUrl: connection.serverUrl, serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username, username: connection.username,
} }
: null; : null;
@@ -127,10 +191,10 @@ export const attachToProject = mutation({
} }
const project = await ctx.db.get(args.projectId); const project = await ctx.db.get(args.projectId);
if (project) { if (project) {
const expected = forgeForHost(project.sourceHost); const expected = providerForHost(project.sourceHost);
if (expected && connection.provider !== expected) { if (expected && connection.provider !== expected) {
throw new ConvexError( throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})` `Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
); );
} }
} }
@@ -141,3 +205,46 @@ export const attachToProject = mutation({
return { attached: true }; return { attached: true };
}, },
}); });
/**
* Backfill legacy gitConnections rows that predate the normalized schema.
* Sets default values for creatorUserId, state, and lastVerifiedAt when
* missing. Also creates a gitProviderAccount for each legacy connection.
*/
export const backfillConnections = internalMutation({
args: {},
handler: async (ctx) => {
const all = await ctx.db.query("gitConnections").collect();
let migrated = 0;
for (const conn of all) {
if (conn.state !== undefined && conn.gitProviderAccountId !== undefined) {
continue;
}
const timestamp = Date.now();
// Create a provider account for the legacy connection.
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: conn.connectedAt,
externalAccountId: conn.username ?? "legacy",
externalUsername: conn.username ?? "legacy",
organizationId: conn.organizationId,
provider: conn.provider,
serverUrl: conn.serverUrl,
status: "reauth-required",
updatedAt: timestamp,
userId: conn.creatorUserId ?? "legacy",
});
// Mark as reauth-required so the health check cron verifies the
// credential before marking active. This prevents trusting legacy
// credentials without verification.
await ctx.db.patch(conn._id, {
creatorUserId: conn.creatorUserId ?? "legacy",
gitProviderAccountId: providerAccountId,
reauthRequiredAt: timestamp,
state: "reauth-required",
updatedAt: timestamp,
});
migrated += 1;
}
return { migrated };
},
});

View File

@@ -0,0 +1,243 @@
"use node";
import {
CREDENTIAL_FRESHNESS_MS,
isCredentialFresh,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import {
action,
internalMutation,
internalQuery,
query,
} from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { decryptCredential } from "./gitConnections";
interface VerifyResult {
readonly lastError?: string;
readonly state: "active" | "reauth-required" | "unavailable";
}
const verifyGiteaCredential = async (
serverUrl: string,
token: string
): Promise<VerifyResult> => {
try {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{ headers: { authorization: `token ${token}` } }
);
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
const verifyGithubCredential = async (token: string): Promise<VerifyResult> => {
try {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
export const verifyCredential = async (
connection: Doc<"gitConnections">
): Promise<VerifyResult> => {
const credential = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
return connection.provider === "gitea"
? verifyGiteaCredential(connection.serverUrl, credential)
: verifyGithubCredential(credential);
};
export const updateConnectionState = internalMutation({
args: {
connectionId: v.id("gitConnections"),
lastError: v.optional(v.string()),
state: v.union(
v.literal("active"),
v.literal("reauth-required"),
v.literal("unavailable")
),
},
handler: async (ctx, args) => {
const timestamp = Date.now();
const patch: Record<string, unknown> = {
lastError: args.lastError,
state: args.state,
updatedAt: timestamp,
};
if (args.state === "active") {
patch.lastError = undefined;
patch.lastVerifiedAt = timestamp;
patch.reauthRequiredAt = undefined;
} else if (args.state === "reauth-required") {
patch.reauthRequiredAt = timestamp;
}
await ctx.db.patch(args.connectionId, patch);
},
});
const getConnectionForOwnerRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const getConnectionRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnection");
const getStaleConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }[]
>("gitConnectionHealth:getStaleConnections");
const updateConnectionStateRef = makeFunctionReference<
"mutation",
{
connectionId: Id<"gitConnections">;
lastError?: string;
state: "active" | "reauth-required" | "unavailable";
},
null
>("gitConnectionHealth:updateConnectionState");
export const getConnectionForOwner = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
return null;
}
return connection;
},
});
export const getConnection = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => await ctx.db.get(args.connectionId),
});
export const getStaleConnections = internalQuery({
args: {},
handler: async (ctx) => {
const now = Date.now();
const cutoff = now - CREDENTIAL_FRESHNESS_MS;
const all = await ctx.db.query("gitConnections").collect();
return all
.filter(
(conn) =>
(conn.state === "active" &&
(conn.lastVerifiedAt === undefined ||
conn.lastVerifiedAt < cutoff)) ||
conn.state === "reauth-required" ||
conn.state === undefined
)
.map((conn) => ({ connectionId: conn._id }));
},
});
export const verify = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const connection = await ctx.runQuery(getConnectionForOwnerRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId: args.connectionId,
lastError: result.lastError,
state: result.state,
});
return result;
},
});
export const isFresh = query({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<boolean> => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
return isCredentialFresh(connection.lastVerifiedAt, Date.now());
},
});
export const reconcileStaleConnections = action({
args: {},
handler: async (ctx) => {
const stale = await ctx.runQuery(getStaleConnectionsRef, {});
let checked = 0;
for (const { connectionId } of stale) {
const connection = await ctx.runQuery(getConnectionRef, {
connectionId,
});
if (!connection) {
continue;
}
try {
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: result.lastError,
state: result.state,
});
checked += 1;
} catch {
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: "Verification failed",
state: "unavailable",
});
}
}
return { checked };
},
});

View File

@@ -2,11 +2,16 @@
import { env } from "@code/env/convex"; import { env } from "@code/env/convex";
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime"; import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
import {
GITHUB_SERVER_URL,
PUTER_GIT_SERVER_URL,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server"; import { action } from "./_generated/server";
import { authComponent, createAuth } from "./auth"; import { authComponent, createAuth } from "./auth";
@@ -52,9 +57,73 @@ export const decryptCredential = async (
return new TextDecoder().decode(decrypted); return new TextDecoder().decode(decrypted);
}; };
interface ExternalUserInfo {
readonly externalAccountId: string;
readonly externalEmail?: string;
readonly externalUsername: string;
}
/** Fetch the Gitea user identity from /api/v1/user using a PAT. */
const fetchGiteaUser = async (
serverUrl: string,
token: string
): Promise<ExternalUserInfo> => {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{
headers: { authorization: `token ${token}` },
}
);
if (!response.ok) {
throw new ConvexError(
`Gitea user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
/** Fetch the GitHub user identity from /user using an OAuth token. */
const fetchGithubUser = async (token: string): Promise<ExternalUserInfo> => {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ConvexError(
`GitHub user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
const syncRepositoriesRef = makeFunctionReference<
"action",
{ connectionId: Id<"gitConnections"> },
{ synced: number }
>("gitConnections:syncRepositories");
export const connectGitea = action({ export const connectGitea = action({
args: { args: {
serverUrl: v.string(),
token: v.string(), token: v.string(),
username: v.optional(v.string()), username: v.optional(v.string()),
}, },
@@ -73,22 +142,32 @@ export const connectGitea = action({
credential: args.token, credential: args.token,
credentialKind: "token", credentialKind: "token",
provider: "gitea", provider: "gitea",
serverUrl: args.serverUrl, serverUrl: PUTER_GIT_SERVER_URL,
username: args.username, username: args.username,
}) })
); );
// Verify the token and fetch external identity before persisting.
const externalUser = await fetchGiteaUser(
connection.serverUrl,
connection.credential
);
const encrypted = await encryptCredential(connection.credential); const encrypted = await encryptCredential(connection.credential);
const connectionId = await ctx.runMutation( const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist, internal.gitConnectionData.persist,
{ {
...encrypted, ...encrypted,
...externalUser,
credentialKind: connection.credentialKind, credentialKind: connection.credentialKind,
provider: connection.provider, provider: connection.provider,
serverUrl: connection.serverUrl, serverUrl: connection.serverUrl,
userId, userId,
username: connection.username, username: connection.username ?? externalUser.externalUsername,
} }
); );
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId }; return { connectionId };
}, },
}); });
@@ -108,17 +187,125 @@ export const connectGithub = action({
if (!token.accessToken) { if (!token.accessToken) {
throw new ConvexError("GitHub account is not connected"); throw new ConvexError("GitHub account is not connected");
} }
const externalUser = await fetchGithubUser(token.accessToken);
const encrypted = await encryptCredential(token.accessToken); const encrypted = await encryptCredential(token.accessToken);
const connectionId = await ctx.runMutation( const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist, internal.gitConnectionData.persist,
{ {
...encrypted, ...encrypted,
...externalUser,
credentialKind: "oauth", credentialKind: "oauth",
provider: "github", provider: "github",
serverUrl: "https://github.com", serverUrl: GITHUB_SERVER_URL,
userId: identity.tokenIdentifier, userId: identity.tokenIdentifier,
} }
); );
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId }; return { connectionId };
}, },
}); });
const getConnectionForOwnerSyncRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const syncRepositoriesBatchRef = makeFunctionReference<
"mutation",
{
providerAccountId: Id<"gitProviderAccounts">;
provider: "github" | "gitea";
repos: {
cloneUrl: string;
defaultBranch: string;
fullName: string;
name: string;
owner: string;
private: boolean;
providerRepositoryId: string;
serverUrl: string;
webUrl: string;
}[];
serverUrl: string;
},
number
>("gitProvisioning:syncRepositoriesBatch");
export const syncRepositories = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<{ synced: number }> => {
const connection = await ctx.runQuery(getConnectionForOwnerSyncRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const token = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
let repos: {
clone_url: string;
default_branch: string;
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
private: boolean;
}[];
if (connection.provider === "gitea") {
const response = await fetch(
`${connection.serverUrl.replace(/\/+$/u, "")}/api/v1/repos/search?limit=50`,
{ headers: { authorization: `token ${token}` } }
);
if (!response.ok) {
throw new ConvexError(
`Failed to list Gitea repositories (${response.status})`
);
}
const body = (await response.json()) as { data: typeof repos };
repos = body.data ?? [];
} else {
const response = await fetch(
"https://api.github.com/user/repos?sort=updated&per_page=50",
{
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new ConvexError(
`Failed to list GitHub repositories (${response.status})`
);
}
repos = (await response.json()) as typeof repos;
}
const synced = await ctx.runMutation(syncRepositoriesBatchRef, {
provider: connection.provider,
providerAccountId:
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
repos: repos.map((repo) => ({
cloneUrl: repo.clone_url,
defaultBranch: repo.default_branch ?? "main",
fullName: repo.full_name,
name: repo.name,
owner: repo.owner.login,
private: repo.private,
providerRepositoryId: String(repo.id),
serverUrl: connection.serverUrl,
webUrl: repo.html_url,
})),
serverUrl: connection.serverUrl,
});
return { synced };
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
"use node";
import { env } from "@code/env/convex";
import {
isProcessedEvent,
MAX_WEBHOOK_PAYLOAD_BYTES,
} from "@code/primitives/git-webhook";
import { makeFunctionReference } from "convex/server";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
const recordWebhookDeliveryRef = makeFunctionReference<
"mutation",
{
deliveryId: string;
event: string;
externalRepositoryId?: string;
payloadHash: string;
provider: "github" | "gitea";
},
{ deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean }
>("gitProvisioning:recordWebhookDelivery");
const resolveRepositoryByExternalIdRef = makeFunctionReference<
"query",
{
externalRepositoryId: string;
provider: "github" | "gitea";
},
{ _id: Id<"gitRepositories"> } | null
>("gitProvisioning:resolveRepositoryByExternalId");
const markDeliveryProcessedRef = makeFunctionReference<
"mutation",
{
deliveryId: Id<"gitWebhookDeliveries">;
repositoryRef?: Id<"gitRepositories">;
},
null
>("gitProvisioning:markDeliveryProcessed");
/* eslint-disable @typescript-eslint/no-explicit-any -- action ctx type is complex without codegen */
const processDelivery = async (
ctx: any,
input: {
readonly deliveryId: string;
readonly event: string;
readonly externalRepositoryId?: string;
readonly payloadHash: string;
readonly provider: "github" | "gitea";
}
): Promise<{ deliveryId: string; duplicate: boolean }> => {
const result: { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } =
await ctx.runMutation(recordWebhookDeliveryRef, input);
if (result.duplicate) {
return result;
}
// Resolve the repository reference if we have an external ID.
let repositoryRef: Id<"gitRepositories"> | undefined;
if (input.externalRepositoryId) {
const repo = await ctx.runQuery(resolveRepositoryByExternalIdRef, {
externalRepositoryId: input.externalRepositoryId,
provider: input.provider,
});
if (repo) {
repositoryRef = repo._id as Id<"gitRepositories">;
}
}
await ctx.runMutation(markDeliveryProcessedRef, {
deliveryId: result.deliveryId,
...(repositoryRef ? { repositoryRef } : {}),
});
return result;
};
const verifyHmacSignature = async (
body: string,
signature: string,
secret: string,
algorithm: "sha256" | "sha1"
): Promise<boolean> => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ hash: algorithm, name: "HMAC" },
false,
["verify"]
);
let sigHex = signature;
if (signature.startsWith("sha256=")) {
sigHex = signature.slice(7);
} else if (signature.startsWith("sha1=")) {
sigHex = signature.slice(5);
}
const sigBytes = new Uint8Array(
sigHex.match(/.{2}/gu)?.flatMap((byte) => Number.parseInt(byte, 16)) ?? []
);
return await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(body)
);
};
const hashPayload = async (body: string): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body)
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
export const githubWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-github-delivery") ?? "";
const event = request.headers.get("x-github-event") ?? "";
const signature = request.headers.get("x-hub-signature-256") ?? "";
if (!deliveryId || !event) {
return new Response("Missing GitHub delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITHUB_WEBHOOK_SECRET) {
return new Response("GitHub webhook secret not configured", {
status: 500,
});
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITHUB_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
// Persist delivery, resolve repository, and mark processed.
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "github",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "github",
});
});
export const puterWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-gitea-delivery") ?? "";
const event = request.headers.get("x-gitea-event") ?? "";
const signature = request.headers.get("x-gitea-signature") ?? "";
if (!deliveryId || !event) {
return new Response("Missing Gitea delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITEA_WEBHOOK_SECRET) {
return new Response("Gitea webhook secret not configured", { status: 500 });
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITEA_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "gitea",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "gitea",
});
});

View File

@@ -1,9 +1,22 @@
import { httpRouter } from "convex/server"; import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth"; import { authComponent, createAuth } from "./auth";
import { githubWebhook, puterWebhook } from "./gitWebhooks";
const http = httpRouter(); const http = httpRouter();
authComponent.registerRoutes(http, createAuth, { cors: true }); authComponent.registerRoutes(http, createAuth, { cors: true });
http.route({
handler: githubWebhook,
method: "POST",
path: "/api/git/webhooks/github",
});
http.route({
handler: puterWebhook,
method: "POST",
path: "/api/git/webhooks/puter",
});
export default http; export default http;

View File

@@ -1,3 +1,4 @@
import { validateProjectInstructions } from "@code/primitives/git-provisioning";
import { import {
decodePublicGitImportResult, decodePublicGitImportResult,
preparePublicGitSource, preparePublicGitSource,
@@ -11,8 +12,12 @@ import { Effect } from "effect";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, mutation, query } from "./_generated/server";
import { requireAuthUserId, requireCurrentOrganization } from "./authz"; import {
requireAuthUserId,
requireCurrentOrganization,
requireProjectMember,
} from "./authz";
import { inspectPublicGit } from "./publicGit"; import { inspectPublicGit } from "./publicGit";
const toProjectView = async ( const toProjectView = async (
@@ -201,3 +206,30 @@ export const importPublicGit = action({
}); });
}, },
}); });
export const updateInstructions = mutation({
args: {
instructions: v.string(),
projectId: v.id("projects"),
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const validated = await Effect.runPromise(
validateProjectInstructions(args.instructions)
);
await ctx.db.patch(args.projectId, {
instructions: validated,
updatedAt: Date.now(),
});
return { updated: true };
},
});
export const getInstructions = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const project = await ctx.db.get(args.projectId);
return project?.instructions ?? "";
},
});

View File

@@ -44,28 +44,168 @@ export default defineSchema({
.index("by_userId", ["userId"]) .index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"]) .index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]), .index("by_organizationId_and_userId", ["organizationId", "userId"]),
gitConnections: defineTable({ gitProviderAccounts: defineTable({
connectedAt: v.number(), createdAt: v.number(),
credentialCiphertext: v.string(), externalAccountId: v.string(),
credentialIv: v.string(), externalEmail: v.optional(v.string()),
credentialKind: v.union(v.literal("oauth"), v.literal("token")), externalUsername: v.string(),
organizationId: v.id("organizations"), organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")), provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(), serverUrl: v.string(),
status: v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
),
updatedAt: v.number(),
userId: v.string(),
})
.index("by_userId_and_provider_and_serverUrl", [
"userId",
"provider",
"serverUrl",
])
.index("by_organizationId", ["organizationId"]),
gitConnections: defineTable({
connectedAt: v.number(),
creatorUserId: v.optional(v.string()),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccountId: v.optional(v.id("gitProviderAccounts")),
grantedScopesJson: v.optional(v.string()),
lastError: v.optional(v.string()),
lastVerifiedAt: v.optional(v.number()),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
reauthRequiredAt: v.optional(v.number()),
serverUrl: v.string(),
state: v.optional(
v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
)
),
updatedAt: v.number(), updatedAt: v.number(),
username: v.optional(v.string()), username: v.optional(v.string()),
}) })
.index("by_organizationId", ["organizationId"]) .index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_provider_and_serverUrl", [ .index("by_gitProviderAccountId", ["gitProviderAccountId"]),
"organizationId", gitProviderOrganizations: defineTable({
createdAt: v.number(),
displayName: v.optional(v.string()),
externalId: v.string(),
externalSlug: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
organizationId: v.id("organizations"),
serverUrl: v.string(),
updatedAt: v.number(),
verificationState: v.union(
v.literal("unverified"),
v.literal("verified"),
v.literal("stale")
),
visibility: v.union(v.literal("public"), v.literal("private")),
})
.index("by_gitProviderAccountId", ["gitProviderAccountId"])
.index("by_organizationId", ["organizationId"]),
gitRepositories: defineTable({
cloneUrl: v.string(),
createdAt: v.number(),
defaultBranch: v.string(),
fullName: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
gitProviderOrganizationId: v.optional(v.id("gitProviderOrganizations")),
lfsCapability: v.union(
v.literal("unknown"),
v.literal("supported"),
v.literal("unsupported")
),
name: v.string(),
organizationId: v.id("organizations"),
owner: v.string(),
permissionsAdmin: v.boolean(),
permissionsMaintain: v.boolean(),
permissionsPull: v.boolean(),
permissionsPush: v.boolean(),
permissionsTriage: v.boolean(),
privacy: v.union(v.literal("public"), v.literal("private")),
provider: v.union(v.literal("github"), v.literal("gitea")),
providerRepositoryId: v.string(),
serverUrl: v.string(),
sourceMigrationId: v.optional(v.id("gitMigrations")),
updatedAt: v.number(),
webUrl: v.string(),
webhookState: v.union(
v.literal("none"),
v.literal("pending"),
v.literal("active"),
v.literal("failed")
),
})
.index("by_provider_and_serverUrl_and_externalRepositoryId", [
"provider", "provider",
"serverUrl", "serverUrl",
]), "providerRepositoryId",
])
.index("by_organizationId", ["organizationId"]),
gitMigrations: defineTable({
createdAt: v.number(),
failureReason: v.optional(v.string()),
githubConnectionId: v.id("gitConnections"),
idempotencyKey: v.string(),
includeLfs: v.boolean(),
organizationId: v.id("organizations"),
puterConnectionId: v.id("gitConnections"),
resultingRepositoryId: v.optional(v.id("gitRepositories")),
sourceIsPrivate: v.boolean(),
sourceRepositoryUrl: v.string(),
status: v.union(
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
v.literal("cancelled")
),
targetOwner: v.string(),
targetRepositoryName: v.string(),
updatedAt: v.number(),
})
.index("by_organizationId", ["organizationId"])
.index("by_idempotencyKey", ["idempotencyKey"]),
gitWebhookDeliveries: defineTable({
createdAt: v.number(),
deliveryId: v.string(),
error: v.optional(v.string()),
event: v.string(),
externalRepositoryId: v.optional(v.string()),
payloadHash: v.string(),
processingState: v.union(
v.literal("received"),
v.literal("processing"),
v.literal("processed"),
v.literal("ignored"),
v.literal("failed"),
v.literal("duplicate")
),
provider: v.union(v.literal("github"), v.literal("gitea")),
repositoryRef: v.optional(v.id("gitRepositories")),
updatedAt: v.number(),
})
.index("by_provider_and_deliveryId", ["provider", "deliveryId"])
.index("by_processingState", ["processingState"]),
projects: defineTable({ projects: defineTable({
createdAt: v.number(), createdAt: v.number(),
defaultBranch: v.optional(v.string()), defaultBranch: v.optional(v.string()),
description: v.optional(v.string()), description: v.optional(v.string()),
gitConnectionId: v.optional(v.id("gitConnections")), gitConnectionId: v.optional(v.id("gitConnections")),
gitRepositoryId: v.optional(v.id("gitRepositories")),
instructions: v.optional(v.string()),
name: v.string(), name: v.string(),
normalizedSourceUrl: v.string(), normalizedSourceUrl: v.string(),
organizationId: v.id("organizations"), organizationId: v.id("organizations"),

View File

@@ -6,13 +6,24 @@ import {
decodeWorkAttemptExecutionFailure, decodeWorkAttemptExecutionFailure,
decodeWorkAttemptExecutionResult, decodeWorkAttemptExecutionResult,
} from "@code/primitives/execution-runtime"; } from "@code/primitives/execution-runtime";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction } from "./_generated/server"; import { internalAction } from "./_generated/server";
import { decryptCredential } from "./gitConnections"; import { decryptCredential } from "./gitConnections";
const getRepositoryRef = makeFunctionReference<
"query",
{ repositoryId: Id<"gitRepositories"> },
{
readonly cloneUrl: string;
readonly defaultBranch: string;
} | null
>("gitProvisioning:getRepository");
const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL; const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL;
export const executeAttempt = internalAction({ export const executeAttempt = internalAction({
@@ -33,6 +44,17 @@ export const executeAttempt = internalAction({
context.connection.credentialCiphertext, context.connection.credentialCiphertext,
context.connection.credentialIv context.connection.credentialIv
); );
// Resolve the project's normalized repository if available; fall back to
// legacy sourceUrl for backward compatibility with pre-backfill projects.
const repository = context.project.gitRepositoryId
? await ctx.runQuery(getRepositoryRef, {
repositoryId: context.project.gitRepositoryId,
})
: null;
const repositoryUrl = repository?.cloneUrl ?? context.project.sourceUrl;
const baseBranch =
repository?.defaultBranch ?? context.project.defaultBranch ?? "main";
const response = await fetch( const response = await fetch(
`${backendUrl()}/internal/work-attempts/execute`, `${backendUrl()}/internal/work-attempts/execute`,
{ {
@@ -44,9 +66,9 @@ export const executeAttempt = internalAction({
serverUrl: context.connection.serverUrl, serverUrl: context.connection.serverUrl,
username: context.connection.username, username: context.connection.username,
}, },
baseBranch: context.project.defaultBranch ?? "main", baseBranch,
prompt: context.prompt, prompt: context.prompt,
repositoryUrl: context.project.sourceUrl, repositoryUrl,
runId: String(context.run._id), runId: String(context.run._id),
workId: String(context.work._id), workId: String(context.work._id),
workspaceKey: context.attempt.workspaceKey, workspaceKey: context.attempt.workspaceKey,

View File

@@ -343,14 +343,29 @@ describe("forge credential validation", () => {
sourceUrl: `https://${sourceHost}/puter/zopu`, sourceUrl: `https://${sourceHost}/puter/zopu`,
updatedAt: 1, updatedAt: 1,
}); });
const connectionId = await ctx.db.insert("gitConnections", { const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
connectedAt: 1, createdAt: 1,
credentialCiphertext: "x", externalAccountId: "ext-1",
credentialIv: "y", externalUsername: "zopu",
credentialKind: provider === "github" ? "oauth" : "token",
organizationId, organizationId,
provider, provider,
serverUrl: `https://${sourceHost}`, serverUrl: `https://${sourceHost}`,
status: "active",
updatedAt: 1,
userId: identity.tokenIdentifier,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
creatorUserId: identity.tokenIdentifier,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
gitProviderAccountId: providerAccountId,
lastVerifiedAt: Date.now(),
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
state: "active",
updatedAt: 1, updatedAt: 1,
username: "zopu", username: "zopu",
}); });
@@ -367,7 +382,7 @@ describe("forge credential validation", () => {
connectionId: ids.connectionId, connectionId: ids.connectionId,
projectId: ids.projectId, projectId: ids.projectId,
}) })
).rejects.toThrow(/does not match this project's forge/u); ).rejects.toThrow(/does not match this project.s provider/u);
}); });
test("accepts a matching provider for the project forge", async () => { test("accepts a matching provider for the project forge", async () => {

View File

@@ -1,5 +1,9 @@
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime"; import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime"; import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
import {
CREDENTIAL_FRESHNESS_MS,
providerForHost as forgeForHost,
} from "@code/primitives/git-provider";
import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver"; import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
import type { AttemptClassification } from "@code/primitives/resolver"; import type { AttemptClassification } from "@code/primitives/resolver";
import { WorkflowManager } from "@convex-dev/workflow"; import { WorkflowManager } from "@convex-dev/workflow";
@@ -11,7 +15,6 @@ import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery, mutation } from "./_generated/server"; import { internalMutation, internalQuery, mutation } from "./_generated/server";
import type { MutationCtx } from "./_generated/server"; import type { MutationCtx } from "./_generated/server";
import { requireProjectMember } from "./authz"; import { requireProjectMember } from "./authz";
import { forgeForHost } from "./gitConnectionData";
export const workflow = new WorkflowManager(components.workflow); export const workflow = new WorkflowManager(components.workflow);
@@ -120,7 +123,21 @@ const validateProjectDeployment = async (
const expected = forgeForHost(project.sourceHost); const expected = forgeForHost(project.sourceHost);
if (expected && connection.provider !== expected) { if (expected && connection.provider !== expected) {
throw new ConvexError( throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})` `Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; verify or reconnect credentials before execution`
);
}
const now = Date.now();
if (
connection.lastVerifiedAt === undefined ||
now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
) {
throw new ConvexError(
"Git credentials have not been verified recently; run a connection health check before execution"
); );
} }
return connection; return connection;
@@ -252,7 +269,12 @@ export const executionContext = internalQuery({
const expectedForge = forgeForHost(project.sourceHost); const expectedForge = forgeForHost(project.sourceHost);
if (expectedForge && connection.provider !== expectedForge) { if (expectedForge && connection.provider !== expectedForge) {
throw new ConvexError( throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expectedForge})` `Git credential provider (${connection.provider}) does not match this project's provider (${expectedForge})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; execution is blocked`
); );
} }
return { return {

View File

@@ -20,7 +20,7 @@ import {
query, query,
} from "./_generated/server"; } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server";
import { requireProjectMember } from "./authz"; import { requireCurrentOrganization, requireProjectMember } from "./authz";
const plannerRef = makeFunctionReference< const plannerRef = makeFunctionReference<
"action", "action",
@@ -868,3 +868,32 @@ export const listForProject = query({
); );
}, },
}); });
export const listForOrganization = query({
args: {},
handler: async (ctx) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const works = await ctx.db
.query("works")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(100);
return await Promise.all(
works.map(async (work) => {
const project = await ctx.db.get(work.projectId);
return {
...work,
project: project
? {
id: String(project._id),
name: project.name,
}
: null,
repositoryReady: Boolean(project?.gitRepositoryId),
};
})
);
},
});

View File

@@ -11,10 +11,13 @@ export const env = createEnv({
FLUE_URL: z.url().optional(), FLUE_URL: z.url().optional(),
GITEA_TOKEN: z.string().min(1).optional(), GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"), GITEA_URL: z.url().default("https://git.openputer.com"),
GITEA_WEBHOOK_SECRET: z.string().min(1).optional(),
GITHUB_CLIENT_ID: z.string().min(1).optional(), GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(), GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
GITHUB_WEBHOOK_SECRET: z.string().min(1).optional(),
GIT_CREDENTIAL_ENCRYPTION_KEY: z.string().min(43).optional(), GIT_CREDENTIAL_ENCRYPTION_KEY: z.string().min(43).optional(),
NATIVE_APP_URL: z.string().min(1).default("code://"), NATIVE_APP_URL: z.string().min(1).default("code://"),
PUTER_GIT_ADMIN_TOKEN: z.string().min(1).optional(),
SITE_URL: z.url(), SITE_URL: z.url(),
}, },
}); });

View File

@@ -8,6 +8,9 @@
"./agent-os": "./src/agent-os.ts", "./agent-os": "./src/agent-os.ts",
"./execution-runtime": "./src/execution-runtime.ts", "./execution-runtime": "./src/execution-runtime.ts",
"./git": "./src/git.ts", "./git": "./src/git.ts",
"./git-provider": "./src/git-provider.ts",
"./git-provisioning": "./src/git-provisioning.ts",
"./git-webhook": "./src/git-webhook.ts",
"./git-local-runtime": "./src/git-local-runtime.ts", "./git-local-runtime": "./src/git-local-runtime.ts",
"./git-remote-runtime": "./src/git-remote-runtime.ts", "./git-remote-runtime": "./src/git-remote-runtime.ts",
"./project": "./src/project.ts", "./project": "./src/project.ts",
@@ -22,7 +25,8 @@
"./work-design": "./src/work-design.ts", "./work-design": "./src/work-design.ts",
"./work-lifecycle": "./src/work-lifecycle.ts", "./work-lifecycle": "./src/work-lifecycle.ts",
"./resolver": "./src/resolver.ts", "./resolver": "./src/resolver.ts",
"./harness-runtime": "./src/harness-runtime.ts" "./harness-runtime": "./src/harness-runtime.ts",
"./host-repository": "./src/host-repository.ts"
}, },
"scripts": { "scripts": {
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
@@ -30,8 +34,8 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@agentos-software/pi": "0.2.7",
"@agentos-software/git": "0.3.3", "@agentos-software/git": "0.3.3",
"@agentos-software/pi": "0.2.7",
"@rivet-dev/agentos": "0.2.14", "@rivet-dev/agentos": "0.2.14",
"effect": "catalog:" "effect": "catalog:"
}, },

View File

@@ -1,6 +1,10 @@
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */ /* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
import { Effect, Schema } from "effect"; import { Effect, Schema } from "effect";
import { GitProvider } from "./git-provider";
export type { GitProvider } from "./git-provider";
const Text = Schema.String.check( const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, { Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string", expected: "a non-empty string",
@@ -21,9 +25,6 @@ const HttpUrl = Schema.String.check(
) )
); );
export const GitProvider = Schema.Literals(["github", "gitea"]);
export type GitProvider = typeof GitProvider.Type;
export const GitCredentialKind = Schema.Literals(["oauth", "token"]); export const GitCredentialKind = Schema.Literals(["oauth", "token"]);
export type GitCredentialKind = typeof GitCredentialKind.Type; export type GitCredentialKind = typeof GitCredentialKind.Type;

View File

@@ -0,0 +1,229 @@
import { Effect } from "effect";
import { describe, expect, test } from "vitest";
import {
canonicalServerUrl,
decideConnectionHostCompatibility,
isCredentialFresh,
isOperational,
isProviderHostCompatible,
normalizeRepositoryUrl,
providerForHost,
} from "./git-provider";
import {
canSafelyReplaceRepository,
idempotencyKeyFor,
isTerminalMigration,
validateProjectInstructions,
validatePuterOrgName,
validatePuterUsername,
validateRepositoryName,
} from "./git-provisioning";
import { isProcessedEvent } from "./git-webhook";
describe("git-provider normalization", () => {
test("normalizes a standard HTTPS GitHub URL", async () => {
const result = await Effect.runPromise(
normalizeRepositoryUrl("https://github.com/owner/repo.git")
);
expect(result.owner).toBe("owner");
expect(result.name).toBe("repo");
expect(result.fullName).toBe("owner/repo");
expect(result.host).toBe("github.com");
});
test("normalizes an SSH git URL", async () => {
const result = await Effect.runPromise(
normalizeRepositoryUrl("git@git.openputer.com:zopu/project.git")
);
expect(result.owner).toBe("zopu");
expect(result.name).toBe("project");
expect(result.host).toBe("git.openputer.com");
});
test("rejects URLs with embedded credentials", async () => {
await expect(
Effect.runPromise(
normalizeRepositoryUrl("https://user:pass@github.com/owner/repo")
)
).rejects.toMatchObject({ reason: "InvalidInput" });
});
test("rejects URLs without owner/name path", async () => {
await expect(
Effect.runPromise(normalizeRepositoryUrl("https://github.com"))
).rejects.toMatchObject({ reason: "InvalidInput" });
});
});
describe("provider/host compatibility", () => {
test("recognizes github.com as github", () => {
expect(providerForHost("github.com")).toBe("github");
});
test("recognizes git.openputer.com as gitea", () => {
expect(providerForHost("git.openputer.com")).toBe("gitea");
});
test("returns null for unknown hosts", () => {
expect(providerForHost("example.com")).toBeNull();
});
test("allows same-provider connections", () => {
expect(
isProviderHostCompatible(
"gitea",
"https://git.openputer.com",
"git.openputer.com"
)
).toBe(true);
});
test("rejects cross-provider connections", async () => {
await expect(
Effect.runPromise(
decideConnectionHostCompatibility(
"gitea",
"https://git.openputer.com",
"github.com"
)
)
).rejects.toMatchObject({ reason: "InvalidInput" });
});
test("returns canonical server URLs", () => {
expect(canonicalServerUrl("github")).toBe("https://github.com");
expect(canonicalServerUrl("gitea")).toBe("https://git.openputer.com");
});
});
describe("connection state decisions", () => {
test("only active is operational", () => {
expect(isOperational("active")).toBe(true);
expect(isOperational("pending-auth")).toBe(false);
expect(isOperational("reauth-required")).toBe(false);
});
test("freshness window is 15 minutes", () => {
const now = Date.now();
expect(isCredentialFresh(now - 14 * 60 * 1000, now)).toBe(true);
expect(isCredentialFresh(now - 16 * 60 * 1000, now)).toBe(false);
expect(isCredentialFresh(undefined, now)).toBe(false);
});
});
describe("provisioning validation", () => {
test("validates a correct Puter username", async () => {
const result = await Effect.runPromise(validatePuterUsername("zopu-user"));
expect(result).toBe("zopu-user");
});
test("rejects invalid Puter username", async () => {
await expect(
Effect.runPromise(validatePuterUsername("-invalid"))
).rejects.toMatchObject({ reason: "InvalidInput" });
});
test("validates a correct repository name", async () => {
const result = await Effect.runPromise(
validateRepositoryName("my-repo.v2")
);
expect(result).toBe("my-repo.v2");
});
test("validates a Puter org name", async () => {
const result = await Effect.runPromise(validatePuterOrgName("zopu-org"));
expect(result).toBe("zopu-org");
});
test("validates project instructions within size limit", async () => {
const result = await Effect.runPromise(
validateProjectInstructions("# Context\nSome notes.")
);
expect(result).toContain("# Context");
});
test("rejects project instructions exceeding limit", async () => {
const long = "x".repeat(10_001);
await expect(
Effect.runPromise(validateProjectInstructions(long))
).rejects.toMatchObject({ reason: "InvalidInput" });
});
});
describe("idempotency and safe replacement", () => {
test("idempotency keys are deterministic", () => {
const key1 = idempotencyKeyFor({
_tag: "CreatePuterUser",
betterAuthEmail: "a@b.com",
betterAuthName: "Test",
organizationId: "org-1",
providerAccountId: "acct-1",
puterUsername: "zopu",
});
const key2 = idempotencyKeyFor({
_tag: "CreatePuterUser",
betterAuthEmail: "a@b.com",
betterAuthName: "Test",
organizationId: "org-1",
providerAccountId: "acct-1",
puterUsername: "zopu",
});
expect(key1).toBe(key2);
expect(key1).toContain("puter-user:acct-1:zopu");
});
test("safe replacement allows same provider/owner/name", () => {
expect(
canSafelyReplaceRepository(
{ name: "repo", owner: "owner", provider: "gitea" },
{ name: "repo", owner: "owner", provider: "gitea" }
)
).toBe(true);
});
test("safe replacement rejects different owner", () => {
expect(
canSafelyReplaceRepository(
{ name: "repo", owner: "owner", provider: "gitea" },
{ name: "repo", owner: "other", provider: "gitea" }
)
).toBe(false);
});
test("safe replacement allows undefined existing", () => {
expect(
canSafelyReplaceRepository(undefined, {
name: "repo",
owner: "owner",
provider: "gitea",
})
).toBe(true);
});
test("terminal migration states are correctly identified", () => {
expect(isTerminalMigration("succeeded")).toBe(true);
expect(isTerminalMigration("failed")).toBe(true);
expect(isTerminalMigration("cancelled")).toBe(true);
expect(isTerminalMigration("queued")).toBe(false);
expect(isTerminalMigration("running")).toBe(false);
});
});
describe("webhook event filtering", () => {
test("processes push events", () => {
expect(isProcessedEvent("push")).toBe(true);
});
test("processes repository events", () => {
expect(isProcessedEvent("repository")).toBe(true);
});
test("ignores ping events", () => {
expect(isProcessedEvent("ping")).toBe(false);
});
test("ignores release events", () => {
expect(isProcessedEvent("release")).toBe(false);
});
});

View File

@@ -0,0 +1,393 @@
/* eslint-disable max-classes-per-file -- normalized git-provider schemas and errors form one domain module. */
import { Effect, Schema } from "effect";
// ---------------------------------------------------------------------------
// Shared filters
// ---------------------------------------------------------------------------
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const HttpUrl = Schema.String.check(
Schema.makeFilter(
(value) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{ expected: "an HTTP URL" }
)
);
// ---------------------------------------------------------------------------
// Provider and host identity
// ---------------------------------------------------------------------------
export const GitProvider = Schema.Literals(["github", "gitea"]);
export type GitProvider = typeof GitProvider.Type;
export const CredentialKind = Schema.Literals(["oauth", "token"]);
export type CredentialKind = typeof CredentialKind.Type;
/** Known canonical server URLs for each provider. */
export const GITHUB_SERVER_URL = "https://github.com";
export const PUTER_GIT_SERVER_URL = "https://git.openputer.com";
export const canonicalServerUrl = (provider: GitProvider): string =>
provider === "github" ? GITHUB_SERVER_URL : PUTER_GIT_SERVER_URL;
/**
* Returns the product-known provider for a host, or null for unrecognized
* hosts. GitHub owns `github.com` and `.githost.com`; Puter Git owns
* `git.openputer.com`.
*/
export const providerForHost = (host: string): GitProvider | null => {
const normalized = host.toLowerCase();
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
return "github";
}
if (normalized === "git.openputer.com") {
return "gitea";
}
return null;
};
/** Whether a host is served by a product-known provider. */
export const isKnownHost = (host: string): boolean =>
providerForHost(host) !== null;
// ---------------------------------------------------------------------------
// Connection states
// ---------------------------------------------------------------------------
export const ConnectionState = Schema.Literals([
"pending-auth",
"active",
"reauth-required",
"revoked",
"unavailable",
]);
export type ConnectionState = typeof ConnectionState.Type;
/**
* Whether a connection state allows operations that use its credential.
* Only `active` is permitted; every other state requires resolution first.
*/
export const isOperational = (state: ConnectionState): boolean =>
state === "active";
// ---------------------------------------------------------------------------
// Normalized errors
// ---------------------------------------------------------------------------
export const GitProviderErrorReason = Schema.Literals([
"InvalidInput",
"Unauthorized",
"Forbidden",
"NotFound",
"Conflict",
"RateLimited",
"Unreachable",
"InvalidResponse",
]);
export type GitProviderErrorReason = typeof GitProviderErrorReason.Type;
export class GitProviderError extends Schema.TaggedErrorClass<GitProviderError>()(
"GitProviderError",
{
message: Schema.String,
reason: GitProviderErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Normalized provider account
// ---------------------------------------------------------------------------
export const ProviderAccount = Schema.Struct({
externalAccountId: Text,
externalEmail: Schema.UndefinedOr(Text),
externalUsername: Text,
provider: GitProvider,
serverUrl: HttpUrl,
});
export type ProviderAccount = typeof ProviderAccount.Type;
// ---------------------------------------------------------------------------
// Normalized provider organization
// ---------------------------------------------------------------------------
export const ProviderOrganization = Schema.Struct({
displayName: Schema.UndefinedOr(Text),
externalId: Text,
externalSlug: Text,
provider: GitProvider,
serverUrl: HttpUrl,
visibility: Schema.Literals(["public", "private"]),
});
export type ProviderOrganization = typeof ProviderOrganization.Type;
// ---------------------------------------------------------------------------
// Repository permissions
// ---------------------------------------------------------------------------
export const RepositoryPermissions = Schema.Struct({
admin: Schema.Boolean,
maintain: Schema.Boolean,
pull: Schema.Boolean,
push: Schema.Boolean,
triage: Schema.Boolean,
});
export type RepositoryPermissions = typeof RepositoryPermissions.Type;
export const FULL_PERMISSIONS: RepositoryPermissions = {
admin: true,
maintain: true,
pull: true,
push: true,
triage: true,
};
// ---------------------------------------------------------------------------
// Normalized repository
// ---------------------------------------------------------------------------
export const RepositoryPrivacy = Schema.Literals(["public", "private"]);
export type RepositoryPrivacy = typeof RepositoryPrivacy.Type;
export const Repository = Schema.Struct({
cloneUrl: HttpUrl,
defaultBranch: Text,
fullName: Text,
lfsCapability: Schema.Literals(["unknown", "supported", "unsupported"]),
name: Text,
owner: Text,
permissions: RepositoryPermissions,
privacy: RepositoryPrivacy,
provider: GitProvider,
providerOrganizationExternalId: Schema.UndefinedOr(Text),
providerRepositoryId: Text,
serverUrl: HttpUrl,
webUrl: HttpUrl,
});
export type Repository = typeof Repository.Type;
// ---------------------------------------------------------------------------
// Connection health
// ---------------------------------------------------------------------------
export const ConnectionHealthResult = Schema.Struct({
checkedAt: Schema.Number,
normalizedError: Schema.UndefinedOr(Text),
state: ConnectionState,
});
export type ConnectionHealthResult = typeof ConnectionHealthResult.Type;
// ---------------------------------------------------------------------------
// Repository URL normalization
// ---------------------------------------------------------------------------
export const NormalizedRepositoryUrl = Schema.Struct({
fullName: Text,
host: Text,
name: Text,
normalizedUrl: Text,
owner: Text,
repositoryPath: Text,
webUrl: Text,
});
export type NormalizedRepositoryUrl = typeof NormalizedRepositoryUrl.Type;
const invalidInput = (message: string) =>
new GitProviderError({ message, reason: "InvalidInput" });
/**
* Normalize a raw Git repository URL (SSH or HTTP) into its canonical
* `owner/name` parts, host, and web URL. Strips credentials, `.git`
* suffixes, and fragments.
*/
export const normalizeRepositoryUrl = (
rawUrl: string
): Effect.Effect<NormalizedRepositoryUrl, GitProviderError> =>
Effect.gen(function* normalize() {
const parsed = (() => {
// Support git@host:owner/name.git SSH URLs.
const scpMatch = /^git@(?<host>[^:]+):(?<path>.+?)(?:\.git)?$/u.exec(
rawUrl
);
if (scpMatch) {
if (!scpMatch.groups) {
return null;
}
const { host, path: repositoryPath } = scpMatch.groups;
return new URL(`https://${host}/${repositoryPath}`);
}
try {
return new URL(rawUrl);
} catch {
return null;
}
})();
if (!parsed) {
return yield* Effect.fail(invalidInput("Invalid repository URL"));
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return yield* Effect.fail(
invalidInput("Repository URL must use http(s) or git SSH protocol")
);
}
// Reject embedded credentials in the URL.
if (parsed.username || parsed.password) {
return yield* Effect.fail(
invalidInput("Repository URL must not contain credentials")
);
}
if (parsed.search || parsed.hash) {
return yield* Effect.fail(
invalidInput("Repository URL must not contain query or fragment")
);
}
const host = parsed.hostname.toLowerCase();
let repositoryPath = parsed.pathname
.replace(/^\/+/u, "")
.replace(/\/+$/u, "");
if (repositoryPath.endsWith(".git")) {
repositoryPath = repositoryPath.slice(0, -4);
}
if (repositoryPath.length === 0) {
return yield* Effect.fail(
invalidInput("Repository URL must include an owner/name path")
);
}
const segments = repositoryPath.split("/").filter((s) => s.length > 0);
if (segments.length < 2) {
return yield* Effect.fail(
invalidInput("Repository URL must include an owner/name path")
);
}
const owner = segments.slice(0, -1).join("/");
const name = segments.at(-1) ?? "";
if (!name) {
return yield* Effect.fail(
invalidInput("Repository URL must include a repository name")
);
}
const fullName = `${owner}/${name}`;
const normalizedUrl = `${parsed.protocol}//${host}/${repositoryPath}`;
const webUrl = normalizedUrl;
return {
fullName,
host,
name,
normalizedUrl,
owner,
repositoryPath,
webUrl,
};
});
/**
* Default branch normalization: trim whitespace and reject empty strings.
* Falls back to a caller-provided default when the source does not supply one.
*/
export const normalizeDefaultBranch = (
raw: string | undefined | null,
fallback = "main"
): string => {
if (!raw) {
return fallback;
}
const trimmed = raw.trim();
return trimmed.length === 0 ? fallback : trimmed;
};
// ---------------------------------------------------------------------------
// Provider/host compatibility
// ---------------------------------------------------------------------------
/**
* Whether a repository host is compatible with a provider's server URL.
* Both must resolve to the same product-known provider, or be the same host.
*/
export const isProviderHostCompatible = (
provider: GitProvider,
serverUrl: string,
repositoryHost: string
): boolean => {
const serverHost = (() => {
try {
return new URL(serverUrl).hostname.toLowerCase();
} catch {
return serverUrl.toLowerCase();
}
})();
const repoHost = repositoryHost.toLowerCase();
if (serverHost === repoHost) {
return true;
}
const providerForServer = providerForHost(serverHost);
const providerForRepo = providerForHost(repoHost);
if (providerForServer === null || providerForRepo === null) {
return false;
}
return providerForServer === provider && providerForRepo === provider;
};
/**
* Decide whether a connection may be used for a repository on the given host.
* Returns a rejection reason when incompatible.
*/
export const decideConnectionHostCompatibility = (
connectionProvider: GitProvider,
connectionServerUrl: string,
repositoryHost: string
): Effect.Effect<void, GitProviderError> =>
Effect.gen(function* decide() {
if (
!isProviderHostCompatible(
connectionProvider,
connectionServerUrl,
repositoryHost
)
) {
return yield* Effect.fail(
invalidInput(
`Connection provider ${connectionProvider} (${connectionServerUrl}) is not compatible with repository host ${repositoryHost}`
)
);
}
});
// ---------------------------------------------------------------------------
// Freshness window for credential verification
// ---------------------------------------------------------------------------
export const CREDENTIAL_FRESHNESS_MS = 15 * 60 * 1000;
/**
* Whether the last successful credential verification is still fresh
* enough for sensitive operations. Returns true when `lastVerifiedAt`
* is within the freshness window of `now`.
*/
export const isCredentialFresh = (
lastVerifiedAt: number | undefined,
now: number
): boolean =>
lastVerifiedAt !== undefined &&
now - lastVerifiedAt <= CREDENTIAL_FRESHNESS_MS;

View File

@@ -0,0 +1,366 @@
/* eslint-disable max-classes-per-file -- provisioning commands, results, and errors form one validated-command module. */
import { Effect, Schema } from "effect";
import type { GitProvider, ProviderAccount } from "./git-provider";
import {
GitProviderError,
RepositoryPrivacy,
PUTER_GIT_SERVER_URL,
} from "./git-provider";
// ---------------------------------------------------------------------------
// Shared filters
// ---------------------------------------------------------------------------
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const HttpUrl = Schema.String.check(
Schema.makeFilter(
(value) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{ expected: "an HTTP URL" }
)
);
// ---------------------------------------------------------------------------
// Migration states
// ---------------------------------------------------------------------------
export const MigrationState = Schema.Literals([
"queued",
"running",
"succeeded",
"failed",
"cancelled",
]);
export type MigrationState = typeof MigrationState.Type;
export const isTerminalMigration = (state: MigrationState): boolean =>
state === "succeeded" || state === "failed" || state === "cancelled";
// ---------------------------------------------------------------------------
// Provisioning command types
// ---------------------------------------------------------------------------
export const CreatePuterUserInput = Schema.Struct({
_tag: Schema.Literal("CreatePuterUser"),
/** Better Auth user identity to provision. */
betterAuthEmail: Text,
betterAuthName: Text,
/** Organization that owns this provisioning request. */
organizationId: Text,
providerAccountId: Text,
/** Username on the Puter Gitea instance. */
puterUsername: Text,
});
export type CreatePuterUserInput = typeof CreatePuterUserInput.Type;
export const CreatePuterUserResult = Schema.Struct({
_tag: Schema.Literal("CreatePuterUser"),
/** Whether the user already existed before this request (idempotent). */
alreadyExisted: Schema.Boolean,
/** External user ID assigned by Gitea. */
externalUserId: Text,
puterUsername: Text,
});
export type CreatePuterUserResult = typeof CreatePuterUserResult.Type;
export const CreatePuterOrganizationInput = Schema.Struct({
_tag: Schema.Literal("CreatePuterOrganization"),
displayName: Schema.UndefinedOr(Text),
organizationId: Text,
providerAccountId: Text,
puterOrgName: Text,
});
export type CreatePuterOrganizationInput =
typeof CreatePuterOrganizationInput.Type;
export const CreatePuterOrganizationResult = Schema.Struct({
_tag: Schema.Literal("CreatePuterOrganization"),
alreadyExisted: Schema.Boolean,
externalOrgId: Text,
puterOrgName: Text,
});
export type CreatePuterOrganizationResult =
typeof CreatePuterOrganizationResult.Type;
export const CreatePuterRepositoryInput = Schema.Struct({
_tag: Schema.Literal("CreatePuterRepository"),
autoInit: Schema.Boolean,
defaultBranch: Text,
description: Schema.UndefinedOr(Text),
organizationId: Text,
owner: Text,
privacy: RepositoryPrivacy,
providerAccountId: Text,
providerOrganizationExternalId: Schema.UndefinedOr(Text),
repositoryName: Text,
});
export type CreatePuterRepositoryInput = typeof CreatePuterRepositoryInput.Type;
export const CreatePuterRepositoryResult = Schema.Struct({
_tag: Schema.Literal("CreatePuterRepository"),
alreadyExisted: Schema.Boolean,
cloneUrl: HttpUrl,
externalRepositoryId: Text,
fullName: Text,
name: Text,
owner: Text,
webUrl: HttpUrl,
});
export type CreatePuterRepositoryResult =
typeof CreatePuterRepositoryResult.Type;
export const MigrateGithubRepositoryInput = Schema.Struct({
_tag: Schema.Literal("MigrateGitHubRepository"),
/** GitHub connection that provides the source token for private repos. */
githubConnectionId: Text,
includeLfs: Schema.Boolean,
organizationId: Text,
/** Puter connection/account used for the migration target. */
puterConnectionId: Text,
/** Whether the source is private and requires the GitHub token. */
sourceIsPrivate: Schema.Boolean,
/** Normalized GitHub source repository URL. */
sourceRepositoryUrl: HttpUrl,
/** Puter target owner (user or org). */
targetOwner: Text,
targetRepositoryName: Text,
});
export type MigrateGithubRepositoryInput =
typeof MigrateGithubRepositoryInput.Type;
export const MigrateGithubRepositoryResult = Schema.Struct({
_tag: Schema.Literal("MigrateGitHubRepository"),
externalRepositoryId: Text,
migrationId: Text,
resultingCloneUrl: HttpUrl,
resultingFullName: Text,
resultingWebUrl: HttpUrl,
});
export type MigrateGithubRepositoryResult =
typeof MigrateGithubRepositoryResult.Type;
// ---------------------------------------------------------------------------
// Idempotency-key construction
// ---------------------------------------------------------------------------
const shortHash = (data: string): string => {
let hash = 0;
for (let i = 0; i < data.length; i += 1) {
const char = data.codePointAt(i);
if (char === undefined) {
continue;
}
hash = (hash * 31 + char) % 4_294_967_295;
}
return hash.toString(16).padStart(8, "0");
};
export type ProvisioningCommand =
| CreatePuterUserInput
| CreatePuterOrganizationInput
| CreatePuterRepositoryInput
| MigrateGithubRepositoryInput;
/** Construct a deterministic idempotency key for a provisioning command. */
export const idempotencyKeyFor = (command: ProvisioningCommand): string => {
switch (command._tag) {
case "CreatePuterUser": {
return `puter-user:${command.providerAccountId}:${command.puterUsername}`;
}
case "CreatePuterOrganization": {
return `puter-org:${command.providerAccountId}:${command.puterOrgName}`;
}
case "CreatePuterRepository": {
return `puter-repo:${command.providerAccountId}:${command.owner}:${command.repositoryName}`;
}
case "MigrateGitHubRepository": {
const sourceHash = shortHash(command.sourceRepositoryUrl);
return `gh-migrate:${command.githubConnectionId}:${command.puterConnectionId}:${sourceHash}:${command.targetOwner}:${command.targetRepositoryName}`;
}
default: {
return "";
}
}
};
// ---------------------------------------------------------------------------
// Safe repository replacement rules
// ---------------------------------------------------------------------------
/**
* Whether replacing an existing active repository on a Project is safe.
* Replacement is permitted only when the new repository's provider and owner
* match the existing one, or when the existing one was never set (undefined).
*/
export const canSafelyReplaceRepository = (
existing: { provider: GitProvider; owner: string; name: string } | undefined,
next: { provider: GitProvider; owner: string; name: string }
): boolean => {
if (!existing) {
return true;
}
return (
existing.provider === next.provider &&
existing.owner === next.owner &&
existing.name === next.name
);
};
// ---------------------------------------------------------------------------
// Project instructions validation
// ---------------------------------------------------------------------------
export const MAX_INSTRUCTIONS_CHARACTERS = 10_000;
export const validateProjectInstructions = (
raw: unknown
): Effect.Effect<string, GitProviderError> =>
Effect.gen(function* validate() {
if (typeof raw !== "string") {
return yield* Effect.fail(
new GitProviderError({
message: "Project instructions must be a string",
reason: "InvalidInput",
})
);
}
if (raw.length > MAX_INSTRUCTIONS_CHARACTERS) {
return yield* Effect.fail(
new GitProviderError({
message: `Project instructions exceed ${MAX_INSTRUCTIONS_CHARACTERS} characters`,
reason: "InvalidInput",
})
);
}
return raw;
});
// ---------------------------------------------------------------------------
// Provisioning validation helpers
// ---------------------------------------------------------------------------
/**
* Validate a username against Puter Gitea's username constraints:
* alphanumeric, hyphens, and underscores; 1-40 characters; must start
* with an alphanumeric character.
*/
export const USERNAME_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-_]{0,39})$/u;
export const validatePuterUsername = (
raw: string
): Effect.Effect<string, GitProviderError> =>
Effect.gen(function* validate() {
if (!USERNAME_PATTERN.test(raw)) {
return yield* Effect.fail(
new GitProviderError({
message:
"Username must be 1-40 characters, start alphanumeric, and contain only letters, digits, hyphens, or underscores",
reason: "InvalidInput",
})
);
}
return raw;
});
/**
* Validate a repository name: alphanumeric, hyphens, underscores, dots;
* must not start/end with a dot or hyphen; 1-100 characters.
*/
export const REPOSITORY_NAME_PATTERN = /^(?:[a-zA-Z0-9][a-zA-Z0-9._-]{0,99})$/u;
export const validateRepositoryName = (
raw: string
): Effect.Effect<string, GitProviderError> =>
Effect.gen(function* validate() {
if (!REPOSITORY_NAME_PATTERN.test(raw)) {
return yield* Effect.fail(
new GitProviderError({
message:
"Repository name must be 1-100 characters, start alphanumeric, and contain only letters, digits, dots, hyphens, or underscores",
reason: "InvalidInput",
})
);
}
if (raw.startsWith(".") || raw.startsWith("-") || raw.endsWith(".")) {
return yield* Effect.fail(
new GitProviderError({
message: "Repository name must not start/end with a dot or hyphen",
reason: "InvalidInput",
})
);
}
return raw;
});
/**
* Validate a Puter organization name (slug): same constraints as username
* but with a separate error path.
*/
export const validatePuterOrgName = (
raw: string
): Effect.Effect<string, GitProviderError> =>
Effect.gen(function* validate() {
if (!USERNAME_PATTERN.test(raw)) {
return yield* Effect.fail(
new GitProviderError({
message:
"Organization name must be 1-40 characters, start alphanumeric, and contain only letters, digits, hyphens, or underscores",
reason: "InvalidInput",
})
);
}
return raw;
});
// ---------------------------------------------------------------------------
// Owner-safe provisioning input guard
// ---------------------------------------------------------------------------
/**
* Enforce that a provisioning command's provider account belongs to the
* authenticated owner. This prevents an owner from inventing arbitrary
* external identities and creating unrelated platform users.
*/
export const assertOwnerSafeProvisioningInput = (
command: { providerAccountId: string },
ownerAccount: ProviderAccount
): Effect.Effect<void, GitProviderError> =>
Effect.gen(function* assert() {
if (command.providerAccountId !== ownerAccount.externalAccountId) {
return yield* Effect.fail(
new GitProviderError({
message:
"Provisioning command must target the authenticated owner's provider account",
reason: "Forbidden",
})
);
}
if (ownerAccount.provider !== "gitea") {
return yield* Effect.fail(
new GitProviderError({
message: "Puter provisioning requires a Gitea provider account",
reason: "InvalidInput",
})
);
}
if (ownerAccount.serverUrl !== PUTER_GIT_SERVER_URL) {
return yield* Effect.fail(
new GitProviderError({
message: `Puter provisioning requires server ${PUTER_GIT_SERVER_URL}`,
reason: "InvalidInput",
})
);
}
});

View File

@@ -0,0 +1,184 @@
/* eslint-disable max-classes-per-file -- webhook event envelopes and errors form one normalized module. */
import { Effect, Schema } from "effect";
import { GitProvider } from "./git-provider";
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
// ---------------------------------------------------------------------------
// Supported event names
// ---------------------------------------------------------------------------
export const GithubEventName = Schema.Literals([
"ping",
"push",
"repository",
"delete",
"create",
"public",
"member",
"organization",
"fork",
"release",
]);
export type GithubEventName = typeof GithubEventName.Type;
export const PuterEventName = Schema.Literals([
"ping",
"push",
"repository",
"create",
"delete",
"fork",
"release",
]);
export type PuterEventName = typeof PuterEventName.Type;
/**
* Events we normalize into repository lifecycle now: repository changes,
* pushes, deletions, transfers, and visibility changes.
*/
export const PROCESSED_EVENTS = new Set([
"push",
"repository",
"delete",
"create",
"public",
"fork",
]);
export const isProcessedEvent = (event: string): boolean =>
PROCESSED_EVENTS.has(event);
// ---------------------------------------------------------------------------
// Signature verification inputs
// ---------------------------------------------------------------------------
export const SignatureVerificationInput = Schema.Struct({
body: Schema.String,
provider: GitProvider,
signature: Text,
/** Hex-encoded HMAC signature from the provider header. */
signatureHeaderName: Text,
token: Text,
});
export type SignatureVerificationInput = typeof SignatureVerificationInput.Type;
// ---------------------------------------------------------------------------
// Webhook delivery processing states
// ---------------------------------------------------------------------------
export const DeliveryState = Schema.Literals([
"received",
"processing",
"processed",
"ignored",
"failed",
"duplicate",
]);
export type DeliveryState = typeof DeliveryState.Type;
export const isTerminalDelivery = (state: DeliveryState): boolean =>
state === "processed" ||
state === "ignored" ||
state === "failed" ||
state === "duplicate";
// ---------------------------------------------------------------------------
// Normalized repository event envelope
// ---------------------------------------------------------------------------
export const RepositoryEventAction = Schema.Literals([
"push",
"created",
"deleted",
"transferred",
"visibility_changed",
"default_branch_changed",
"renamed",
"forked",
"other",
]);
export type RepositoryEventAction = typeof RepositoryEventAction.Type;
export const RepositoryEventEnvelope = Schema.Struct({
action: RepositoryEventAction,
/** Provider-assigned delivery GUID. */
deliveryId: Text,
event: Text,
/** External repository ID from the provider payload. */
externalRepositoryId: Text,
/** Full owner/name from the provider payload. */
fullName: Text,
owner: Text,
provider: GitProvider,
pushedTo: Schema.UndefinedOr(Text),
/** Ref or branch that changed, when applicable. */
ref: Schema.UndefinedOr(Text),
refType: Schema.UndefinedOr(Text),
repositoryName: Text,
/** ISO-like timestamp from the provider, or epoch 0 when absent. */
repositoryUpdatedAt: Schema.Number,
serverUrl: Text,
visibility: Schema.UndefinedOr(Text),
});
export type RepositoryEventEnvelope = typeof RepositoryEventEnvelope.Type;
// ---------------------------------------------------------------------------
// Webhook errors
// ---------------------------------------------------------------------------
export const WebhookErrorReason = Schema.Literals([
"InvalidInput",
"Unauthorized",
"NotFound",
"Conflict",
"InvalidResponse",
"PayloadTooLarge",
]);
export type WebhookErrorReason = typeof WebhookErrorReason.Type;
export class WebhookError extends Schema.TaggedErrorClass<WebhookError>()(
"WebhookError",
{
message: Schema.String,
reason: WebhookErrorReason,
}
) {}
export const MAX_WEBHOOK_PAYLOAD_BYTES = 10 * 1024 * 1024;
/**
* Apply a payload-size limit before JSON decoding. Rejects payloads larger
* than the maximum before any parsing.
*/
export const assertPayloadSize = (
byteLength: number
): Effect.Effect<void, WebhookError> =>
Effect.gen(function* assert() {
if (byteLength > MAX_WEBHOOK_PAYLOAD_BYTES) {
return yield* Effect.fail(
new WebhookError({
message: `Webhook payload exceeds ${MAX_WEBHOOK_PAYLOAD_BYTES} bytes`,
reason: "PayloadTooLarge",
})
);
}
});
// ---------------------------------------------------------------------------
// Delivery idempotency helpers
// ---------------------------------------------------------------------------
/**
* Construct the idempotency key for a webhook delivery. Duplicate deliveries
* with the same provider/deliveryId are no-ops.
*/
export const deliveryIdempotencyKey = (
provider: GitProvider,
deliveryId: string
): string => `webhook:${provider}:${deliveryId}`;

View File

@@ -0,0 +1,188 @@
/* eslint-disable max-classes-per-file -- host repository preparation service and error form one contract. */
import type { Effect } from "effect";
import { Context, Schema } from "effect";
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const HttpUrl = Schema.String.check(
Schema.makeFilter(
(value) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{ expected: "an HTTP URL" }
)
);
/** Input for preparing a repository checkout on the host. */
export const HostRepositoryInput = Schema.Struct({
baseBranch: Text,
cloneUrl: HttpUrl,
/** Decrypted credential for the provider (PAT or OAuth token). */
credential: Schema.UndefinedOr(Text),
/** Provider type: github uses x-access-token as the username, gitea uses
* the account's external username. */
provider: Schema.UndefinedOr(Text),
/** Unique workspace key for isolation. */
username: Schema.UndefinedOr(Text),
workspaceKey: Text,
});
export type HostRepositoryInput = typeof HostRepositoryInput.Type;
/** Result of preparing a repository checkout on the host. */
export const HostRepositoryResult = Schema.Struct({
/** The base revision checked out. */
baseRevision: Text,
checkoutPath: Text,
});
export type HostRepositoryResult = typeof HostRepositoryResult.Type;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export const HostRepositoryErrorReason = Schema.Literals([
"Authentication",
"CloneFailed",
"CheckoutFailed",
"InvalidInput",
"Timeout",
]);
export type HostRepositoryErrorReason = typeof HostRepositoryErrorReason.Type;
export class HostRepositoryError extends Schema.TaggedErrorClass<HostRepositoryError>()(
"HostRepositoryError",
{
message: Schema.String,
reason: HostRepositoryErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// HostRepository service
// ---------------------------------------------------------------------------
/**
* Provider-neutral host repository preparation. Clones/fetches the repository
* using the decrypted credential, removes the credential from the remote URL
* and git config, and mounts the checkout into the AgentOS VM.
*
* The VM never receives the provider credential. It edits and runs Git locally.
* The host collects the diff and performs authenticated push/delivery later.
*
* Future `sandbox credential lease` boundary is documented but not implemented.
*/
interface HostRepositoryShape {
readonly prepare: (
input: HostRepositoryInput
) => Effect.Effect<HostRepositoryResult, HostRepositoryError>;
}
export class HostRepository extends Context.Service<
HostRepository,
HostRepositoryShape
>()("@code/primitives/host-repository/HostRepository") {}
// ---------------------------------------------------------------------------
// Credential-safe clone URL construction
// ---------------------------------------------------------------------------
/**
* Build a clone URL with credentials embedded for the initial clone only.
* The credential is removed from the remote URL immediately after cloning
* so it never persists in git config.
*/
export const buildAuthenticatedCloneUrl = (
cloneUrl: string,
credential?: string
): string => {
if (!credential) {
return cloneUrl;
}
try {
const url = new URL(cloneUrl);
// Inject the token as a Bearer credential in the URL.
// For GitHub: x-access-token:<token>@
// For Gitea: the token is used as the username with no password.
url.username = credential;
return url.toString();
} catch {
return cloneUrl;
}
};
/**
* Strip credentials from a git remote URL. Called after cloning to ensure
* the credential does not persist in `.git/config`.
*/
export const stripCredentialsFromUrl = (url: string): string => {
try {
const parsed = new URL(url);
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
};
// ---------------------------------------------------------------------------
// Shell command builder for credential-safe clone
// ---------------------------------------------------------------------------
/**
* Build the git arguments for cloning a repository safely. Returns arrays
* of string arguments suitable for `execFileSync` (no shell interpolation).
*
* 1. Clone with credentials (if provided)
* 2. Remove credentials from the remote URL
* 3. Checkout the base branch
* 4. Capture the base revision
*
* Using argument arrays instead of shell strings prevents credential-driven
* command injection. The credential is never written to git config permanently.
*/
export const buildCloneSteps = (input: {
baseBranch: string;
checkoutPath: string;
cloneUrl: string;
credential?: string;
}): readonly {
readonly args: readonly string[];
readonly description: string;
}[] => {
const { baseBranch, checkoutPath, cloneUrl, credential } = input;
const authenticatedUrl = buildAuthenticatedCloneUrl(cloneUrl, credential);
const cleanUrl = stripCredentialsFromUrl(cloneUrl);
return [
{
args: ["clone", "--no-tags", authenticatedUrl, checkoutPath],
description: "git clone",
},
{
args: ["-C", checkoutPath, "remote", "set-url", "origin", cleanUrl],
description: "strip credentials from remote URL",
},
{
args: ["-C", checkoutPath, "checkout", baseBranch],
description: "checkout base branch",
},
{
args: ["-C", checkoutPath, "rev-parse", "HEAD"],
description: "capture base revision",
},
];
};

View File

@@ -2,6 +2,9 @@
export * from "./agent-os.ts"; export * from "./agent-os.ts";
export * from "./execution-runtime.ts"; export * from "./execution-runtime.ts";
export * from "./git.ts"; export * from "./git.ts";
export * from "./git-provider.ts";
export * from "./git-provisioning.ts";
export * from "./git-webhook.ts";
export * from "./git-local-runtime.ts"; export * from "./git-local-runtime.ts";
export * from "./git-remote-runtime.ts"; export * from "./git-remote-runtime.ts";
export * from "./project.ts"; export * from "./project.ts";
@@ -17,3 +20,4 @@ export * from "./work-design.ts";
export * from "./work-lifecycle.ts"; export * from "./work-lifecycle.ts";
export * from "./resolver.ts"; export * from "./resolver.ts";
export * from "./harness-runtime.ts"; export * from "./harness-runtime.ts";
export * from "./host-repository.ts";