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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user