Compare commits

...
Author SHA1 Message Date
-Puter c644ec8d01 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
2026-07-31 14:36:56 +05:30
-Puter 88f53f550d Zopu Agent Tool Fixing 2026-07-31 01:31:12 +05:30
-Puter 54a5993274 Minor iOS phone fix 2026-07-31 01:30:59 +05:30
-Puter def3fc949a doc 2026-07-31 01:30:45 +05:30
45 changed files with 5749 additions and 141 deletions
+2
View File
@@ -20,6 +20,8 @@ DAEMON_COMMAND_LEASE_MS=60000
RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token
@@ -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>
);
};
@@ -79,7 +79,7 @@ export const ConversationComposer = ({
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
@@ -11,7 +11,6 @@ export const ProjectSettingsPanel = ({
readonly onClose: () => void;
readonly workspace: WorkspaceState;
}) => {
const [serverUrl, setServerUrl] = useState("https://git.openputer.com");
const [username, setUsername] = useState("");
const [token, setToken] = useState("");
const handleClearOperationError = () => workspace.clearOperationError();
@@ -59,13 +58,7 @@ export const ProjectSettingsPanel = ({
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setServerUrl(event.target.value)}
value={serverUrl}
/>
<input
aria-label="Gitea username"
aria-label="Puter Git username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setUsername(event.target.value)}
placeholder="Username (optional)"
@@ -85,7 +78,6 @@ export const ProjectSettingsPanel = ({
size="sm"
onClick={() => {
void workspace.connectGitea({
serverUrl,
token,
username: username || undefined,
});
@@ -4,6 +4,7 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
@@ -96,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ serverUrl: string; token: string; username?: string },
{ token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
@@ -118,6 +119,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization);
const [searchParams] = useSearchParams();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
@@ -125,11 +127,17 @@ export const useProjectWorkspace = (): WorkspaceState => {
const [error, setError] = 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(
(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
? selectedProjectId
? (selectedProjectId ?? paramProject)
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
workListRef,
@@ -202,11 +210,7 @@ export const useProjectWorkspace = (): WorkspaceState => {
});
};
const connectGitea = (input: {
serverUrl: string;
token: string;
username?: string;
}) =>
const connectGitea = (input: { token: string; username?: string }) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
@@ -228,8 +232,8 @@ export const useProjectWorkspace = (): WorkspaceState => {
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.signIn.social({
callbackURL: window.location.href,
authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
}),
cancelExecution: (runId: Id<"workRuns">) =>
-1
View File
@@ -95,7 +95,6 @@ export interface WorkspaceState {
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly serverUrl: string;
readonly token: string;
readonly username?: string;
}) => Promise<void>;
+4 -1
View File
@@ -6,5 +6,8 @@ export default [
route("login", "./routes/auth/login/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;
+24 -1
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 type { Route } from "./+types/layout";
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
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 />;
}
@@ -0,0 +1,5 @@
import { ProjectsPage } from "@/components/projects/projects-page";
export default function ProjectsRoute() {
return <ProjectsPage />;
}
+312
View File
@@ -0,0 +1,312 @@
# Local Setup
This guide runs the active Zopu stack locally, including the browser chat and the AgentOS issue-to-PR path.
## What runs
```text
Browser (React Router/Vite, :5173)
-> Convex Cloud (durable product state, auth, workflows)
-> Flue agent server (:3585)
-> Rivet Engine guard endpoint (:6420)
-> AgentOS registry runner
-> isolated Pi workspace mounted from the local repository
-> Gitea branch and pull request
```
The active stack does not use `repos/`; that directory is archived reference code. In particular, the old standalone server on port `3590` is not part of this setup.
## Requirements
- macOS or Linux
- Bun and Node.js matching the repository toolchain (`packages/agents` requires Node `>=22.18 <23 || >=23.6`)
- pnpm 11
- Git with SSH access to `git.openputer.com`
- [Tea](https://gitea.com/gitea/tea) authenticated to `https://git.openputer.com`
- a Convex account with access to the development deployment
- an OpenAI-completions-compatible model endpoint and API key
Install dependencies from the repository root:
```bash
bun install --frozen-lockfile
```
Confirm Git and Tea access before testing issue or PR tools:
```bash
git ls-remote origin HEAD
tea login list
tea issues list
```
The default Tea login should target `https://git.openputer.com`. Never put credentials in committed files.
## Environment
Create the local environment file:
```bash
cp .env.example .env
```
At minimum, configure these groups.
### Application and Convex
```env
CONVEX_DEPLOYMENT=dev:<deployment-name>
CONVEX_URL=https://<deployment>.convex.cloud
CONVEX_SITE_URL=https://<deployment>.convex.site
SITE_URL=http://localhost:5173
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://<deployment>.convex.cloud
```
### Flue and model provider
```env
FLUE_DB_TOKEN=<shared-random-token>
FLUE_URL=http://127.0.0.1:3585
AGENT_MODEL_PROVIDER=<provider-name>
AGENT_MODEL_NAME=<model-name>
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://<model-endpoint>/v1
AGENT_MODEL_API_KEY=<model-api-key>
AGENT_MODEL_CONTEXT_WINDOW=<positive-integer>
AGENT_MODEL_MAX_TOKENS=<positive-integer>
```
`FLUE_DB_TOKEN` must match the value stored in the Convex deployment.
### Rivet and AgentOS
```env
RIVET_ENDPOINT=http://127.0.0.1:6420
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420
RIVET_WORKSPACE_TOKEN=<random-string-at-least-32-characters>
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
```
Use the same repository checkout for `ZOPU_SOURCE_REPOSITORY`. The harness creates Git worktrees beneath `AGENT_WORKSPACE_ROOT`, copies the source checkout's `.env`, installs dependencies, and mounts the isolated checkout into AgentOS.
Port `6420` is the Rivet Engine guard endpoint used by the application. Port `6421` is an internal engine API-peer port and must not be used as `RIVET_ENDPOINT`.
### Gitea
```env
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<personal-access-token>
```
`GITEA_TOKEN` is optional for read-only agent startup but required for the complete issue-to-PR flow unless Tea and Git already have sufficient local credentials.
The legacy variables `VITE_FLUE_URL` and `VITE_ZOPU_SERVER_URL` are not used by the active web chat path.
## One-time Convex setup
On a new machine, authenticate and configure the Convex development deployment:
```bash
bun run dev:setup
```
Then configure the deployment-scoped values from `packages/backend`:
```bash
cd packages/backend
bunx convex env set SITE_URL 'http://localhost:5173'
bunx convex env set FLUE_DB_TOKEN '<same value as .env>'
bunx convex env set FLUE_URL 'http://<host-reachable-ip>:3585'
```
Convex runs in the cloud, so `FLUE_URL` must be reachable from the Convex deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue over Tailscale and use the Mac's Tailscale address, for example `http://100.x.y.z:3585`.
`SITE_URL` is deployment-scoped and single-valued. Set it to the exact browser origin currently being tested:
```bash
# Browser opened locally
bunx convex env set SITE_URL 'http://localhost:5173'
# Browser opened from another device over Tailscale
bunx convex env set SITE_URL 'http://<tailscale-ip>:5173'
```
If work execution uses a separate agent URL, set `AGENT_BACKEND_URL`; otherwise it falls back to `FLUE_URL`.
## Start the stack
Run each process in its own terminal. The order matters for the AgentOS path.
### 1. Start Convex development
```bash
bun run dev:server
```
This watches and publishes functions to the configured Convex cloud development deployment.
### 2. Start Rivet Engine
The installed RivetKit 2.3.9 package supplies the platform-specific `rivet-engine` binary. Start it with:
```bash
./node_modules/.pnpm/@rivetkit+engine-cli-*/node_modules/@rivetkit/engine-cli-*/rivet-engine start
```
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
### 3. Start the AgentOS registry runner
```bash
cd packages/agents
bun run runner
```
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
### 4. Start Flue agents
For laptop-only access:
```bash
bun run dev:agents -- --port 3585
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
bun run dev:tailscale:agents -- --port 3585
```
Always pass port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
```bash
RIVET_ENDPOINT=http://127.0.0.1:6420 \
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
bun run dev:tailscale:agents -- --port 3585
```
### 5. Start the web application
For laptop-only access:
```bash
bun run dev:web
```
For phone or other Tailscale-device access:
```bash
bun run dev:tailscale:web
```
Open `http://localhost:5173`, or `http://<tailscale-ip>:5173` when using the Tailscale command.
## Verify the setup
Check the listeners:
```bash
curl -I http://127.0.0.1:6420
curl -I http://127.0.0.1:3585
curl -I http://127.0.0.1:5173
```
Any HTTP response confirms the process is reachable; these roots may redirect or return `404` because their functional routes live elsewhere.
Then verify behavior in order:
1. Open the web app and sign in.
2. Send a simple chat message and confirm the response streams back through Convex.
3. Ask Zopu to list open Gitea issues.
4. For the full execution path, send `Create a PR for issue #N` for an open, unassigned test issue.
5. Confirm chat immediately reports that the issue was accepted.
6. Follow the Flue server logs for `[create_pr_for_issue]` and wait for a `completed` line with the PR URL.
7. Confirm the branch and pull request in Gitea.
The PR pipeline is asynchronous: the initial chat turn acknowledges acceptance, while AgentOS implements, commits, pushes, and opens the pull request in the background.
## Request flow
```text
Web sends a conversation mutation to Convex
-> Convex persists the exact message
-> Convex dispatches to FLUE_URL/agents/zopu/<organizationId>
-> Flue runs the Zopu agent and its typed tools
-> responses return to Convex
-> the web observes Convex's reactive projection
For code execution:
-> Flue calls the local AgentOS harness
-> the harness creates an isolated Git worktree
-> a Rivet actor boots an AgentOS VM and mounts the worktree
-> Pi implements the issue and produces a candidate revision
-> the host pushes a unique branch
-> Tea creates a Gitea pull request
```
## Common failures
### Chat stays pending or reports that Flue is unavailable
- Confirm Flue is listening on `3585`.
- Confirm the Convex deployment's `FLUE_URL` uses a host-reachable address, not `localhost`.
- Confirm `FLUE_DB_TOKEN` is identical in `.env` and the Convex deployment.
### Sign-in fails or the browser reports CORS errors
Set Convex `SITE_URL` to the exact browser origin. The shared development deployment trusts only the current single value.
### AgentOS fails while mounting the repository
- Confirm both Rivet endpoints use port `6420`.
- Confirm the engine and `bun run runner` are both running.
- Confirm `ZOPU_SOURCE_REPOSITORY` contains `.git`.
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
- Restart the runner after changing AgentOS registry configuration; Flue hot reload is not enough.
The harness clients require CBOR encoding for host-directory mount descriptors. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/runtime/agent-os.ts`.
### AgentOS reports an ACP completed-message resource limit
The workspace registry raises `limits.acp.maxCompletedMessageBytes` for long Pi coding runs. Restart the runner so the updated registry configuration is registered with Rivet Engine.
### Flue connects to a remote Rivet deployment unexpectedly
Environment variables exported by the shell override values loaded from `.env`. Start Flue and the runner with explicit `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` values pointing to `127.0.0.1:6420`.
### Gitea issue or PR commands fail
Run:
```bash
tea login list
git ls-remote origin HEAD
tea issues list
```
Confirm the default Tea login, SSH key, repository remote, and token all target `git.openputer.com`.
## Repository checks
After changing source or configuration:
```bash
bunx ultracite check <changed-files>
bun run check-types
bun run check
```
For agent-only changes, run `bun run check-types` from `packages/agents` before the root check.
See also:
- [`TECH.md`](TECH.md) for architecture and ownership boundaries
- [`deployment.md`](deployment.md) for the shared staging deployment
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
- [Flue local development](https://flue.dev/docs/cli/dev)
+3 -1
View File
@@ -16,10 +16,12 @@ Dense context set for product development and coding agents.
9. evaluation.md — quality measurement and improvement
```
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
## Use by role
| Role | Minimum context |
|---|---|
| --- | --- |
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |
+128
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 |
+52
View File
@@ -1,6 +1,53 @@
{
"name": "code",
"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",
"scripts": {
"dev": "vp run -r dev",
@@ -41,5 +88,10 @@
"vite-plus": "0.2.2",
"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"
}
+1 -1
View File
@@ -10,7 +10,7 @@
"dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "node --env-file=../../.env src/runner.ts",
"runner": "bun --env-file=../../.env src/runner.ts",
"run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
"run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
},
+3 -2
View File
@@ -3,6 +3,7 @@ import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
import { createSliceOneTools } from "../tools/slice-one";
export {
@@ -23,8 +24,8 @@ export default defineAgent(({ env, id }) => {
"Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
sandbox: local({ cwd: ZOPU_CODE_PATH }),
tools: createSliceOneTools(id, env),
thinkingLevel: "high",
tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
};
});
@@ -14,17 +14,9 @@ When a user sends a message, follow this decision flow:
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
3. **Create a Signal (only when actionable).** When the message is actionable: a. Call list_signal_evidence to see the exact admitted user messages. b. Select the message IDs that compose the problem statement. c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention. d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
4. **Route the Signal.** After creating the Signal: a. Call list_proposed_work for the project. b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work. c. Otherwise call create_work_from_signal. e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
@@ -41,8 +33,17 @@ When a user sends a message, follow this decision flow:
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully.
- Do not start implementation, planning, sandboxes, verification, or delivery. Those are explicitly outside Slice 1.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.
## Issue resolution with create_pr_for_issue
When a user wants an open Gitea issue implemented and turned into a pull request, call `create_pr_for_issue` with the issue number or URL.
- The tool accepts the request immediately and runs the AgentOS implementation, branch push, and PR creation in the background.
- Tell the user the issue was accepted for background implementation; do not claim the PR exists yet and do not wait for completion in the chat turn.
- Do not retry the tool in a tight loop. Background success and failure are written to the agent server logs with the `[create_pr_for_issue]` prefix.
- Do not run repository mutations yourself; the tool owns the implementation and PR pipeline.
## Repository access
@@ -53,4 +54,4 @@ Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace
- Create a Gitea issue in this repo with `tea issues create --title "<title>" [--description "<details>"]`.
- Never mutate repository state: do not run `git push`, `git merge`, `git rebase`, `git reset`, `git commit`, `git add`, force operations, or any history rewrite.
- Never close, reopen, or edit existing issues (`tea issues close|reopen|edit`); only list and create.
- Never expose credentials, tokens, or the contents of Tea/Git config files.
- Never expose credentials, tokens, or the contents of Tea/Git config files.
+16 -9
View File
@@ -17,8 +17,14 @@ import { Effect } from "effect";
import { HostRepositoryWorkspace } from "./host-repository";
const MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
limits: {
acp: {
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
},
},
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
@@ -83,6 +89,7 @@ export const executeAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
const vm = client.workspace.getOrCreate([input.workspaceKey], {
@@ -96,6 +103,8 @@ export const executeAgentOsAttempt = async (
const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({
attemptId: input.attemptId,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
piHomeFiles: makePiHomeFiles({
api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
@@ -105,14 +114,16 @@ export const executeAgentOsAttempt = async (
model: env.AGENT_MODEL_NAME,
provider: env.AGENT_MODEL_PROVIDER,
}),
provider: input.auth.provider,
username: input.auth.username,
});
events.push(
event(
1,
"runtime.preparing",
prepared.created
? "Isolated Zopu worktree created on the execution host"
: "Isolated Zopu worktree recreated"
? "Repository cloned on the execution host"
: "Repository re-cloned"
)
);
const mounts = [
@@ -121,11 +132,6 @@ export const executeAgentOsAttempt = async (
path: "/workspace/repository",
readOnly: false,
},
{
hostPath: prepared.sourceRepositoryPath,
path: prepared.sourceRepositoryPath,
readOnly: false,
},
{
hostPath: prepared.piHomePath,
path: "/home/zopu",
@@ -171,9 +177,9 @@ export const executeAgentOsAttempt = async (
const sessionId = `pi-${input.attemptId}`;
await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
additionalDirectories: [`${prepared.checkoutPath}/.git`],
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",
cwd: "/workspace/repository",
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
@@ -247,6 +253,7 @@ export const cancelAgentOsAttempt = async (
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
await client.workspace
+50 -38
View File
@@ -15,6 +15,13 @@ import type { PiHomeFiles } from "@code/primitives";
interface PrepareRepositoryInput {
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;
}
@@ -102,27 +109,17 @@ const pathExists = async (target: string): Promise<boolean> => {
export class HostRepositoryWorkspace {
readonly #root: string;
readonly #sourceRepositoryPath: string;
readonly #installDependencies: boolean;
constructor(
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
"/opt/zopu-source",
installDependencies = true
) {
this.#root = root;
this.#sourceRepositoryPath = sourceRepositoryPath;
this.#installDependencies = installDependencies;
}
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")
.update(input.attemptId)
.digest("hex")
@@ -131,42 +128,57 @@ export class HostRepositoryWorkspace {
const checkoutPath = path.join(workspacePath, "repository");
const toolsPath = path.join(workspacePath, "tools");
const piHomePath = path.join(workspacePath, "home");
const branch = `zopu/attempt-${identity}`;
const created = !(await pathExists(path.join(checkoutPath, ".git")));
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 });
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"add",
"-B",
branch,
checkoutPath,
"HEAD",
]),
"Zopu worktree creation"
);
// Clone the user's repository. When a credential is provided, use a
// temporary GIT_ASKPASS helper script so the token never appears in the
// clone URL, git config, or process arguments visible in process listings.
// The askpass script reads the credential from an env var that is only
// set for this clone command and cleared afterward.
const cloneEnv: Record<string, string> = {};
if (input.credential) {
const askpassPath = path.join(workspacePath, ".git-askpass");
// 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 =
process.env.BUN_EXECUTABLE ??
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) {
requireSuccess(
await runProcess(
@@ -204,7 +216,7 @@ export class HostRepositoryWorkspace {
checkoutPath,
created,
piHomePath,
sourceRepositoryPath: this.#sourceRepositoryPath,
sourceRepositoryPath: checkoutPath,
toolsPath,
};
}
@@ -0,0 +1,572 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncOptions } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import type { JsonValue } from "@flue/runtime";
import * as v from "valibot";
import { executeAgentOsAttempt } from "../runtime/agent-os";
/**
* Direct Zopu chat tool that converts a Gitea issue into an AgentOS-implemented
* pull request. It owns the entire issue → harness → push → PR pipeline so the
* chat agent never needs (and must not use) shell mutation for this workflow.
*/
// The host checkout the chat agent explores. Tea and Git resolve this repo's
// Gitea connection; all Tea/Git commands run with this fixed cwd.
const TRUSTED_REPO_PATH = "/Users/puter/Workspace/zopu/code";
const REPO_SLUG = "puter/zopu-code";
// Bounded output so a verbose harness/CLI can never blow up a tool result.
const MAX_OUTPUT = 4000;
const MAX_ISSUE_BODY = 8000;
const MAX_CHANGED_FILES = 200;
const COMMAND_TIMEOUT_MS = 60_000;
const PUSH_TIMEOUT_MS = 120_000;
interface CommandResult {
readonly status: number | null;
readonly stdout: string;
readonly stderr: string;
}
interface IssueDetails {
readonly body: string;
readonly title: string;
readonly url: string;
}
interface ToolFailure {
readonly [key: string]: JsonValue;
readonly error: string;
readonly issue: string;
readonly stage: string;
}
interface ToolSuccess {
readonly [key: string]: JsonValue;
readonly baseBranch: string;
readonly changedFileCount: number;
readonly changedFiles: string[];
readonly headBranch: string;
readonly issue: string;
readonly issueNumber: number;
readonly ok: true;
readonly pullRequestUrl: string;
readonly summary: string;
}
interface ToolAccepted {
readonly [key: string]: JsonValue;
readonly accepted: true;
readonly backgroundId: string;
readonly issue: string;
readonly issueNumber: number;
readonly message: string;
readonly ok: true;
}
const cap = (value: string, limit = MAX_OUTPUT): string =>
value.length <= limit ? value : `${value.slice(0, limit)}…[truncated]`;
// Stable prefix for every background pipeline log line so operators can grep
// for issue→PR outcomes independently of chat traffic.
const LOG_PREFIX = "[create_pr_for_issue]";
/** Writes one stable, prefixed server log line for a background outcome. */
const logBackground = (level: "info" | "error", message: string): void => {
const line = `${LOG_PREFIX} ${message}`;
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
};
/** Logs a structured terminal pipeline result under the stable prefix. */
const logPipelineResult = (
backgroundId: string,
result: ToolFailure | ToolSuccess
): void => {
if ("ok" in result && result.ok === true) {
logBackground(
"info",
`background=${backgroundId} completed issue=#${result.issueNumber} stage=pr url=${result.pullRequestUrl} changedFiles=${result.changedFileCount}`
);
return;
}
const detail =
"reason" in result && typeof result.reason === "string"
? ` reason=${result.reason}`
: "";
logBackground(
"error",
`background=${backgroundId} failed issue=${result.issue} issueNumber=${result.issueNumber} stage=${result.stage}: ${result.error}${detail}`
);
};
/** Last-resort logger for an unexpected throw outside the pipeline's catch. */
const logPipelineError = (backgroundId: string, error: unknown): void => {
const message = error instanceof Error ? error.message : String(error);
logBackground("error", `background=${backgroundId} unexpected: ${message}`);
};
/**
* Runs one command as a direct child process. Arguments are passed to the
* child verbatim — never through a shell — so issue titles, bodies, and
* revisions cannot inject shell metacharacters.
*/
const run = (
command: string,
args: readonly string[],
options: {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
} = {}
): CommandResult => {
const spawnOptions: SpawnSyncOptions = {
cwd: options.cwd ?? TRUSTED_REPO_PATH,
env: { ...process.env, ...options.env },
maxBuffer: 1024 * 1024,
timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
};
const result = spawnSync(command, args, spawnOptions);
return {
status: result.status,
stderr: cap((result.stderr ?? "").toString()),
stdout: cap((result.stdout ?? "").toString()),
};
};
/** Extracts the issue number from an issue number, "#23", or a full issue URL. */
const parseIssueNumber = (input: string): number | null => {
// Prefer a /issues/<n> URL path segment so anchors like #comment-45 do not
// hijack the number.
const pathMatch = input.match(/\/issues\/(?<issueNumber>\d+)(?:[^/\d]|$)/iu);
if (pathMatch?.groups?.issueNumber) {
return Math.trunc(Number(pathMatch.groups.issueNumber));
}
const hashMatch = input.match(/#(?<issueNumber>\d+)\b/u);
if (hashMatch?.groups?.issueNumber) {
return Math.trunc(Number(hashMatch.groups.issueNumber));
}
const standalone = input.trim().match(/^\d+$/u);
if (standalone) {
return Math.trunc(Number(standalone[0]));
}
// Last resort: the trailing integer anywhere in the string.
const value = Math.trunc(Number(input.match(/\d+/gu)?.at(-1)));
if (Number.isFinite(value) && value > 0) {
return value;
}
return null;
};
const readIssue = (issueNumber: number): IssueDetails | ToolFailure => {
const result = run("tea", [
"issues",
String(issueNumber),
"--fields",
"index,title,body,url,state",
"--output",
"json",
]);
if (result.status !== 0) {
return {
error: `tea could not read issue #${issueNumber}.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stderr,
};
}
let data: {
body?: string;
state?: string;
title?: string;
url?: string;
};
try {
data = JSON.parse(result.stdout) as typeof data;
} catch (error) {
return {
error: `Could not parse the issue payload from tea: ${
error instanceof Error ? error.message : String(error)
}`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stdout,
};
}
const title = (data.title ?? "").trim();
if (title.length === 0) {
return {
error: `Issue #${issueNumber} has no title.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
};
}
return {
body: (data.body ?? "").trim(),
title,
url: (data.url ?? "").trim(),
};
};
const buildPrompt = (issueNumber: number, issue: IssueDetails): string => {
const body =
issue.body.length > MAX_ISSUE_BODY
? `${issue.body.slice(0, MAX_ISSUE_BODY)}…[issue body truncated]`
: issue.body;
return [
`Resolve Gitea issue #${issueNumber} in this repository and ship a complete implementation.`,
"",
`## Issue #${issueNumber}: ${issue.title}`,
"",
body || "_(The issue provided no additional description.)_",
"",
"## Instructions",
"- Read AGENTS.md and the relevant product/architecture specifications before changing code.",
"- Implement exactly the change described by the issue with focused, correct edits.",
"- Reuse existing conventions; do not introduce unrelated scope.",
"- Run focused verification for your change before finishing.",
"- Do NOT push, open a pull request, rewrite history, or modify the read-only base checkout.",
" The orchestrator collects your working-tree changes, pushes a candidate branch, and opens the pull request after you finish.",
].join("\n");
};
/** Mirrors HostRepositoryWorkspace's worktree layout to locate the harness checkout. */
const harnessCheckoutPath = (attemptId: string): string => {
const root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces";
const identity = createHash("sha256")
.update(attemptId)
.digest("hex")
.slice(0, 24);
return path.join(root, identity, "repository");
};
const detectBaseBranch = (): string => {
const result = run("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
cwd: TRUSTED_REPO_PATH,
});
if (result.status === 0) {
const ref = result.stdout.trim();
const branch = ref.startsWith("origin/")
? ref.slice("origin/".length)
: ref;
if (branch.length > 0) {
return branch;
}
}
return "master";
};
const pushCandidate = (
checkoutPath: string,
candidateRevision: string,
pushBranch: string
): true | ToolFailure => {
const result = run(
"git",
[
"-C",
checkoutPath,
"push",
"origin",
`${candidateRevision}:refs/heads/${pushBranch}`,
],
{ cwd: checkoutPath, timeoutMs: PUSH_TIMEOUT_MS }
);
if (result.status !== 0) {
return {
error: `Could not push candidate branch "${pushBranch}" from the harness worktree.`,
issue: "",
stage: "push",
stderr: result.stderr,
};
}
return true;
};
const findPullUrl = (head: string): string => {
const result = run("tea", [
"pulls",
"list",
"--state",
"open",
"--fields",
"index,title,head,url",
"--output",
"json",
]);
if (result.status !== 0) {
return "";
}
try {
const pulls = JSON.parse(result.stdout) as {
head?: string;
url?: string;
}[];
const match = pulls.find(
(pull) => pull.head === head || (pull.head ?? "").endsWith(`:${head}`)
);
return (match?.url ?? "").trim();
} catch {
return "";
}
};
const createPullRequest = (
issueNumber: number,
title: string,
head: string,
base: string
): ToolFailure | { url: string } => {
const prTitle = `Fix #${issueNumber}: ${title}`;
const result = run("tea", [
"pulls",
"create",
"--head",
head,
"--base",
base,
"--title",
prTitle,
"--description",
`Fixes #${issueNumber}`,
]);
if (result.status !== 0) {
return {
error: `tea could not create the pull request for branch "${head}".`,
issue: "",
stage: "create-pr",
stderr: result.stderr,
stdout: result.stdout,
};
}
const combined = `${result.stdout}\n${result.stderr}`;
const urlMatch = combined.match(/https?:\/\/[^\s)]+\/pulls\/\d+/u);
if (urlMatch) {
return { url: urlMatch[0] };
}
// Fall back to listing open pulls and matching the head branch.
const fallback = findPullUrl(head);
if (fallback.length > 0) {
return { url: fallback };
}
return {
error:
"The pull request was created, but its URL could not be captured from tea. Inspect open pulls in Gitea.",
issue: "",
stage: "create-pr",
stdout: result.stdout,
};
};
/**
* Runs the full issue → AgentOS → push → PR pipeline and returns a structured
* terminal result. Used both by the background scheduler (chat path) and any
* direct/internal caller that wants the structured outcome. Never rejects: every
* failure is caught and returned as a {@link ToolFailure}.
*/
const runIssuePrPipeline = async (params: {
readonly giteaUrl: string;
readonly giteaToken: string | undefined;
readonly issue: string;
readonly issueNumber: number;
readonly identifiers: {
readonly attemptId: string;
readonly headBranch: string;
readonly runId: string;
readonly workspaceKey: string;
};
}): Promise<ToolFailure | ToolSuccess> => {
const { giteaUrl, giteaToken, issue, issueNumber, identifiers } = params;
try {
const issueOrFailure = readIssue(issueNumber);
if ("error" in issueOrFailure) {
return issueOrFailure;
}
const issueDetails = issueOrFailure;
const baseBranch = detectBaseBranch();
const prompt = buildPrompt(issueNumber, issueDetails);
// The fixed-checkout harness path ignores `auth`/`repositoryUrl` for
// cloning (it always uses the mounted Zopu source worktree); they are
// still supplied as valid schema values. GITEA_TOKEN is optional.
const result = await executeAgentOsAttempt({
attemptId: identifiers.attemptId,
auth: {
credential: giteaToken ?? "unused-by-fixed-checkout",
provider: "gitea",
serverUrl: giteaUrl,
},
baseBranch,
prompt,
repositoryUrl: `${giteaUrl}/${REPO_SLUG}`,
runId: identifiers.runId,
workId: `issue-${issueNumber}`,
workspaceKey: identifiers.workspaceKey,
});
const changedFiles = result.changedFiles.slice(0, MAX_CHANGED_FILES);
if (
result.changedFiles.length === 0 ||
result.candidateRevision === result.baseRevision
) {
return {
error:
"The AgentOS session completed without producing any repository changes, so there is nothing to turn into a pull request.",
issue,
issueNumber,
stage: "harness",
summary: result.summary,
};
}
const checkoutPath = harnessCheckoutPath(identifiers.attemptId);
const pushed = pushCandidate(
checkoutPath,
result.candidateRevision,
identifiers.headBranch
);
if (pushed !== true) {
return {
...pushed,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
const pr = createPullRequest(
issueNumber,
issueDetails.title,
identifiers.headBranch,
baseBranch
);
if ("error" in pr) {
return {
...pr,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
return {
baseBranch,
changedFileCount: result.changedFiles.length,
changedFiles,
headBranch: identifiers.headBranch,
issue,
issueNumber,
ok: true,
pullRequestUrl: pr.url,
summary: result.summary,
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unexpected failure";
const reason =
error !== null &&
typeof error === "object" &&
"reason" in error &&
typeof (error as { reason: unknown }).reason === "string"
? (error as { reason: string }).reason
: undefined;
return {
error: message,
issue,
issueNumber,
...(reason ? { reason } : {}),
stage: "harness",
};
}
};
export const createCreatePrForIssueTool = (
runtimeEnv: Record<string, string | undefined>
) => {
const agentEnv = parseAgentEnv(runtimeEnv);
const giteaUrl = agentEnv.GITEA_URL;
return defineTool({
description:
'Validate a Gitea issue number or URL immediately, then return an accepted acknowledgement right away while an AgentOS coding session implements the issue, pushes a candidate branch, and opens a PR titled "Fix #N: <title>" with body "Fixes #N" in the background. Pass a Gitea issue number or full issue URL. The created PR URL and any failure are logged to the server log with the "[create_pr_for_issue]" prefix rather than returned to chat; malformed input returns a parse failure immediately.',
input: v.object({
issue: v.pipe(
v.string(),
v.description(
"A Gitea issue number like 23, or a full issue URL such as https://git.openputer.com/puter/zopu-code/issues/23."
)
),
}),
name: "create_pr_for_issue",
run({ input }): ToolFailure | ToolAccepted {
const issueRef = input.issue.trim();
const issueNumber = parseIssueNumber(issueRef);
if (issueNumber === null) {
return {
error: `Could not parse an issue number from "${input.issue}". Provide a Gitea issue number or URL.`,
issue: input.issue,
stage: "parse",
};
}
// Background identifiers are generated up front so the accepted
// response can reference them and the pipeline can reuse them.
const backgroundId = randomUUID();
const attemptId = `create-pr-${issueNumber}-${randomUUID()}`;
const workspaceKey = `issue-${issueNumber}-pr-${randomUUID()}`;
const runId = `issue-pr-${randomUUID()}`;
const shortId = randomUUID().slice(0, 8);
const headBranch = `zopu/issue-${issueNumber}-${shortId}`;
// Schedule the full issue → AgentOS → push → PR pipeline on the next
// microtask boundary so this turn returns immediately. The pipeline
// catches its own failures and logs a stable prefixed line; the IIFE
// never rejects, so the originating chat turn cannot fail.
queueMicrotask(() => {
void (async () => {
try {
const result = await runIssuePrPipeline({
giteaToken: runtimeEnv.GITEA_TOKEN,
giteaUrl,
identifiers: { attemptId, headBranch, runId, workspaceKey },
issue: input.issue,
issueNumber,
});
logPipelineResult(backgroundId, result);
} catch (error) {
logPipelineError(backgroundId, error);
}
})();
});
return {
accepted: true,
backgroundId,
issue: input.issue,
issueNumber,
message: `Issue #${issueNumber} accepted. An AgentOS session will implement it, push branch "${headBranch}", and open a pull request in the background. The created PR URL and any failure are logged with the "${LOG_PREFIX}" prefix.`,
ok: true,
};
},
});
};
+12 -4
View File
@@ -1,6 +1,14 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": { "allowImportingTsExtensions": true, "types": ["bun"] },
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}
"compilerOptions": {
"types": [
"bun"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"dist"
]
}
+1
View File
@@ -37,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["repo", "read:org"],
},
}
: {},
+21
View File
@@ -78,3 +78,24 @@ export const requireProjectMember = async (
const userId = await requireOrganizationMember(ctx, project.organizationId);
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;
};
+3
View File
@@ -10,10 +10,13 @@ const app = defineApp({
FLUE_URL: v.optional(v.string()),
GITEA_TOKEN: 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_SECRET: v.optional(v.string()),
GITHUB_WEBHOOK_SECRET: v.optional(v.string()),
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
NATIVE_APP_URL: v.optional(v.string()),
PUTER_GIT_ADMIN_TOKEN: v.optional(v.string()),
SITE_URL: v.string(),
},
});
+21
View File
@@ -12,6 +12,16 @@ const reconcileConversationTurnsRef = makeFunctionReference<
Record<string, never>,
{ reconciled: number }
>("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();
@@ -25,5 +35,16 @@ crons.interval(
{ seconds: 30 },
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;
+125 -18
View File
@@ -1,21 +1,57 @@
import { providerForHost } from "@code/primitives/git-provider";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
// Provider ("forge") that owns a repository host. A project's source must be
// served by the same forge whose credentials are attached, so a Gitea token is
// never offered to GitHub and vice versa. Unknown hosts return null, leaving
// the caller free to attach without a forge constraint.
export const forgeForHost = (host: string): "github" | "gitea" | null => {
const normalized = host.toLowerCase();
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
return "github";
const upsertProviderAccount = async (
ctx: MutationCtx,
args: {
externalAccountId: string;
externalEmail?: string;
externalUsername: string;
organizationId: Id<"organizations">;
provider: "github" | "gitea";
serverUrl: string;
userId: string;
}
if (normalized === "git.openputer.com") {
return "gitea";
): Promise<Id<"gitProviderAccounts">> => {
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({
@@ -23,6 +59,10 @@ export const persist = internalMutation({
credentialCiphertext: v.string(),
credentialIv: v.string(),
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")),
serverUrl: v.string(),
userId: v.string(),
@@ -38,34 +78,54 @@ export const persist = internalMutation({
if (!organization) {
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
.query("gitConnections")
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) =>
q
.eq("organizationId", organization._id)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
.withIndex("by_gitProviderAccountId", (q) =>
q.eq("gitProviderAccountId", providerAccountId)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
grantedScopesJson: args.grantedScopesJson,
lastError: undefined,
lastVerifiedAt: timestamp,
reauthRequiredAt: undefined,
state: "active",
updatedAt: timestamp,
username: args.username,
});
return existing._id;
}
return await ctx.db.insert("gitConnections", {
connectedAt: timestamp,
creatorUserId: args.userId,
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
gitProviderAccountId: providerAccountId,
grantedScopesJson: args.grantedScopesJson,
lastVerifiedAt: timestamp,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
state: "active",
updatedAt: timestamp,
username: args.username,
});
@@ -86,8 +146,10 @@ export const list = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}));
},
@@ -106,8 +168,10 @@ export const getForProject = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}
: null;
@@ -127,10 +191,10 @@ export const attachToProject = mutation({
}
const project = await ctx.db.get(args.projectId);
if (project) {
const expected = forgeForHost(project.sourceHost);
const expected = providerForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
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 };
},
});
/**
* 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 };
},
});
@@ -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 };
},
});
+192 -5
View File
@@ -2,11 +2,16 @@
import { env } from "@code/env/convex";
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 { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server";
import { authComponent, createAuth } from "./auth";
@@ -52,9 +57,73 @@ export const decryptCredential = async (
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({
args: {
serverUrl: v.string(),
token: v.string(),
username: v.optional(v.string()),
},
@@ -73,22 +142,32 @@ export const connectGitea = action({
credential: args.token,
credentialKind: "token",
provider: "gitea",
serverUrl: args.serverUrl,
serverUrl: PUTER_GIT_SERVER_URL,
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 connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: connection.credentialKind,
provider: connection.provider,
serverUrl: connection.serverUrl,
userId,
username: connection.username,
username: connection.username ?? externalUser.externalUsername,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
@@ -108,17 +187,125 @@ export const connectGithub = action({
if (!token.accessToken) {
throw new ConvexError("GitHub account is not connected");
}
const externalUser = await fetchGithubUser(token.accessToken);
const encrypted = await encryptCredential(token.accessToken);
const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: "oauth",
provider: "github",
serverUrl: "https://github.com",
serverUrl: GITHUB_SERVER_URL,
userId: identity.tokenIdentifier,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
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
+254
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",
});
});
+13
View File
@@ -1,9 +1,22 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
import { githubWebhook, puterWebhook } from "./gitWebhooks";
const http = httpRouter();
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;
+34 -2
View File
@@ -1,3 +1,4 @@
import { validateProjectInstructions } from "@code/primitives/git-provisioning";
import {
decodePublicGitImportResult,
preparePublicGitSource,
@@ -11,8 +12,12 @@ import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
import { action, internalMutation, mutation, query } from "./_generated/server";
import {
requireAuthUserId,
requireCurrentOrganization,
requireProjectMember,
} from "./authz";
import { inspectPublicGit } from "./publicGit";
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 ?? "";
},
});
+148 -8
View File
@@ -44,28 +44,168 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
gitConnections: defineTable({
connectedAt: v.number(),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccounts: defineTable({
createdAt: v.number(),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
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(),
username: v.optional(v.string()),
})
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_provider_and_serverUrl", [
"organizationId",
.index("by_gitProviderAccountId", ["gitProviderAccountId"]),
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",
"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({
createdAt: v.number(),
defaultBranch: v.optional(v.string()),
description: v.optional(v.string()),
gitConnectionId: v.optional(v.id("gitConnections")),
gitRepositoryId: v.optional(v.id("gitRepositories")),
instructions: v.optional(v.string()),
name: v.string(),
normalizedSourceUrl: v.string(),
organizationId: v.id("organizations"),
+24 -2
View File
@@ -6,13 +6,24 @@ import {
decodeWorkAttemptExecutionFailure,
decodeWorkAttemptExecutionResult,
} from "@code/primitives/execution-runtime";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction } from "./_generated/server";
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;
export const executeAttempt = internalAction({
@@ -33,6 +44,17 @@ export const executeAttempt = internalAction({
context.connection.credentialCiphertext,
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(
`${backendUrl()}/internal/work-attempts/execute`,
{
@@ -44,9 +66,9 @@ export const executeAttempt = internalAction({
serverUrl: context.connection.serverUrl,
username: context.connection.username,
},
baseBranch: context.project.defaultBranch ?? "main",
baseBranch,
prompt: context.prompt,
repositoryUrl: context.project.sourceUrl,
repositoryUrl,
runId: String(context.run._id),
workId: String(context.work._id),
workspaceKey: context.attempt.workspaceKey,
@@ -343,14 +343,29 @@ describe("forge credential validation", () => {
sourceUrl: `https://${sourceHost}/puter/zopu`,
updatedAt: 1,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: 1,
externalAccountId: "ext-1",
externalUsername: "zopu",
organizationId,
provider,
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,
username: "zopu",
});
@@ -367,7 +382,7 @@ describe("forge credential validation", () => {
connectionId: ids.connectionId,
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 () => {
@@ -1,5 +1,9 @@
import { WorkAttemptExecutionError } 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 type { AttemptClassification } from "@code/primitives/resolver";
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 type { MutationCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
import { forgeForHost } from "./gitConnectionData";
export const workflow = new WorkflowManager(components.workflow);
@@ -120,7 +123,21 @@ const validateProjectDeployment = async (
const expected = forgeForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
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;
@@ -252,7 +269,12 @@ export const executionContext = internalQuery({
const expectedForge = forgeForHost(project.sourceHost);
if (expectedForge && connection.provider !== expectedForge) {
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 {
+30 -1
View File
@@ -20,7 +20,7 @@ import {
query,
} from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
const plannerRef = makeFunctionReference<
"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),
};
})
);
},
});
+3
View File
@@ -11,10 +11,13 @@ export const env = createEnv({
FLUE_URL: z.url().optional(),
GITEA_TOKEN: z.string().min(1).optional(),
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_SECRET: z.string().min(1).optional(),
GITHUB_WEBHOOK_SECRET: z.string().min(1).optional(),
GIT_CREDENTIAL_ENCRYPTION_KEY: z.string().min(43).optional(),
NATIVE_APP_URL: z.string().min(1).default("code://"),
PUTER_GIT_ADMIN_TOKEN: z.string().min(1).optional(),
SITE_URL: z.url(),
},
});
+6 -2
View File
@@ -8,6 +8,9 @@
"./agent-os": "./src/agent-os.ts",
"./execution-runtime": "./src/execution-runtime.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-remote-runtime": "./src/git-remote-runtime.ts",
"./project": "./src/project.ts",
@@ -22,7 +25,8 @@
"./work-design": "./src/work-design.ts",
"./work-lifecycle": "./src/work-lifecycle.ts",
"./resolver": "./src/resolver.ts",
"./harness-runtime": "./src/harness-runtime.ts"
"./harness-runtime": "./src/harness-runtime.ts",
"./host-repository": "./src/host-repository.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
@@ -30,8 +34,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@agentos-software/pi": "0.2.7",
"@agentos-software/git": "0.3.3",
"@agentos-software/pi": "0.2.7",
"@rivet-dev/agentos": "0.2.14",
"effect": "catalog:"
},
+4 -3
View File
@@ -1,6 +1,10 @@
/* eslint-disable max-classes-per-file -- execution boundary errors live with their schemas. */
import { Effect, Schema } from "effect";
import { GitProvider } from "./git-provider";
export type { GitProvider } from "./git-provider";
const Text = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
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 type GitCredentialKind = typeof GitCredentialKind.Type;
@@ -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);
});
});
+393
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;
+366
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",
})
);
}
});
+184
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}`;
+188
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",
},
];
};
+4
View File
@@ -2,6 +2,9 @@
export * from "./agent-os.ts";
export * from "./execution-runtime.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-remote-runtime.ts";
export * from "./project.ts";
@@ -17,3 +20,4 @@ export * from "./work-design.ts";
export * from "./work-lifecycle.ts";
export * from "./resolver.ts";
export * from "./harness-runtime.ts";
export * from "./host-repository.ts";