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
+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";