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

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

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

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

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

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
This commit is contained in:
-Puter
2026-07-31 14:36:56 +05:30
parent 88f53f550d
commit c644ec8d01
36 changed files with 4821 additions and 119 deletions
+8 -9
View File
@@ -103,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",
@@ -112,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 = [
@@ -128,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",
@@ -178,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),
+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,
};
}