Fix sandbox git publishing and large workspace uploads (#8422)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox and SSH runtimes need to preserve agent work across isolated execution environments > - Git-backed workspaces were being copied mostly as filesystem archives, which breaks when `.git` points outside the mounted workspace and makes sandbox agents unable to publish their own branches > - Large ignored dependency trees could also be swept into the sandbox overlay, causing multi-GB transfers and max-string failures in some sandbox clients > - This pull request makes sandbox runtime setup use a git-backed HEAD sync plus a small dirty/untracked overlay, and bounds sandbox file transfers so large archives do not need one huge string > - The benefit is that sandbox agents can commit and push from a usable git checkout without uploading dependency trees such as `node_modules` ## Linked Issues or Issue Description Refs #8395 No public duplicate issue or PR was found after searches for `sandbox git workspace`, `git push sandbox`, and `node_modules sandbox upload`. Bug report: - What happened: sandbox-backed agent workspaces could receive a `.git` file that pointed at host-only git state, leaving the sandbox unable to run normal git workflows. The sandbox overlay upload could also include ignored dependency directories, creating very large transfers. - Expected behavior: sandbox and remote runtimes should prepare a usable git-backed workspace, copy only the necessary workspace overlay, and restore git history plus file changes without depending on a host-only `.git` path. - Steps to reproduce: 1. Run an agent in a sandbox-backed workspace whose local git checkout is a worktree. 2. Ask the agent to complete a GitHub workflow that requires commit/push access. 3. Observe that git operations can fail inside the sandbox, and ignored dependency trees can be uploaded as part of the workspace overlay. - Paperclip version or commit: reproduced against `master` before this PR, base `7aa212296eb1`. - Deployment mode: local dev / sandbox-backed runtime. - Installation method: built from source. - Agent adapters involved: local adapters using shared adapter-utils runtime preparation. - Database mode: not database-related. - Access context: agent runtime. - Local verification environment: Node.js v25.6.1, pnpm 9.15.4, macOS arm64. - Privacy checklist: all pasted output was reviewed for secrets, private hostnames, local usernames, and internal instance links. ## What Changed - Added a GitHub workflow push preflight so agent runs can detect missing push credentials when a workflow explicitly needs GitHub publishing. - Added shared git workspace sync helpers for shallow HEAD import/export and dirty/untracked overlay tracking. - Updated sandbox managed runtime setup to use git history plus a selected overlay instead of uploading the full local workspace for git-backed workspaces. - Bounded sandbox archive upload/download paths so large payloads stream or chunk instead of materializing one oversized string. - Excluded `.git` and ignored dependency trees from sandbox upload, download, and restore baselines while preserving local ignored directories during sync-back. - Added focused tests for git workspace sync, sandbox overlay selection, transfer chunking, and heartbeat push-preflight behavior. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/git-workspace-sync.test.ts packages/adapter-utils/src/sandbox-managed-runtime.test.ts packages/adapter-utils/src/command-managed-runtime.test.ts server/src/__tests__/heartbeat-project-env.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm build` - Public-hygiene scan of the PR diff and commit messages for internal issue ids, local paths, private hostnames, and obvious token patterns. ## Risks - Medium risk: this changes sandbox runtime synchronization semantics for git-backed workspaces, especially around dirty tracked files, untracked files, deleted paths, and ignored files. - The main mitigation is focused test coverage for upload contents, restore exclusions, and git round-trip behavior. - The SSH runtime keeps the current bundle-based implementation from `master`; this PR only aligns shared excludes and sandbox behavior with that model. ## Model Used OpenAI Codex, GPT-5-based coding agent, tool-enabled shell/git/GitHub workflow, with code execution and repository inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -313,6 +313,82 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
details: { code: "low_trust_inline_sensitive_env_denied" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails push-capability preflight when no GitHub write credential is bound at agent or project scope", async () => {
|
||||
await expect(resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
|
||||
projectEnv: { PROJECT_ONLY: "project-only" },
|
||||
requiredScopedEnvBinding: {
|
||||
keys: ["GH_TOKEN", "GITHUB_TOKEN"],
|
||||
consumerScopes: ["agent", "project"],
|
||||
reason: "push_write_credential_missing",
|
||||
remediation: "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.",
|
||||
},
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime: vi.fn(),
|
||||
resolveEnvBindings: vi.fn(),
|
||||
} as any,
|
||||
})).rejects.toMatchObject({
|
||||
code: "configuration_incomplete",
|
||||
message: expect.stringContaining("GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN"),
|
||||
resultJson: {
|
||||
configurationIncomplete: {
|
||||
reason: "push_write_credential_missing",
|
||||
requiredEnvKeys: ["GH_TOKEN", "GITHUB_TOKEN"],
|
||||
requiredScopes: ["agent", "project"],
|
||||
missingBindings: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes push-capability preflight when a project-scoped GitHub credential is configured", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||
config: { env: { AGENT_ONLY: "agent-only" } },
|
||||
secretKeys: new Set<string>(),
|
||||
manifest: [],
|
||||
});
|
||||
const resolveEnvBindings = vi.fn().mockResolvedValue({
|
||||
env: { GH_TOKEN: "github-token" },
|
||||
secretKeys: new Set(["GH_TOKEN"]),
|
||||
manifest: [],
|
||||
});
|
||||
const collectMissingRuntimeBindings = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const result = await resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
projectId: "project-1",
|
||||
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
|
||||
projectEnv: { GH_TOKEN: { type: "plain", value: "github-token" } },
|
||||
requiredScopedEnvBinding: {
|
||||
keys: ["GH_TOKEN", "GITHUB_TOKEN"],
|
||||
consumerScopes: ["agent", "project"],
|
||||
reason: "push_write_credential_missing",
|
||||
remediation: "GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.",
|
||||
},
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime,
|
||||
resolveEnvBindings,
|
||||
collectMissingRuntimeBindings,
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.resolvedConfig.env).toEqual({
|
||||
AGENT_ONLY: "agent-only",
|
||||
GH_TOKEN: "github-token",
|
||||
});
|
||||
expect(resolveEnvBindings).toHaveBeenCalledOnce();
|
||||
expect(collectMissingRuntimeBindings).toHaveBeenCalledTimes(2);
|
||||
expect(collectMissingRuntimeBindings.mock.calls[1]?.[2]).toMatchObject({
|
||||
consumerType: "project",
|
||||
consumerId: "project-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMentionedSkillIdsFromSources", () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { agents } from "@paperclipai/db";
|
||||
import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server";
|
||||
@@ -5,6 +10,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import {
|
||||
applyPersistedExecutionWorkspaceConfig,
|
||||
assertGitSensitiveAdapterWorkspaceValid,
|
||||
assertPushCapabilityCheckoutValid,
|
||||
buildRealizedExecutionWorkspaceFromPersisted,
|
||||
buildExplicitResumeSessionOverride,
|
||||
deriveTaskKeyWithHeartbeatFallback,
|
||||
@@ -16,6 +22,7 @@ import {
|
||||
prioritizeProjectWorkspaceCandidatesForRun,
|
||||
parseSessionCompactionPolicy,
|
||||
resolveNextSessionState,
|
||||
requiresPushCapabilityPreflight,
|
||||
resolveWorkspaceAfterLowTrustPreflight,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
shouldDeferFollowupWakeForSameIssue,
|
||||
@@ -29,6 +36,8 @@ import {
|
||||
} from "../services/heartbeat.ts";
|
||||
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
|
||||
return {
|
||||
cwd: "/tmp/project",
|
||||
@@ -105,6 +114,19 @@ function buildWorkspaceValidationInput(
|
||||
};
|
||||
}
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFile("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function createGitCheckout(options: { withRemote: boolean }) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-push-preflight-"));
|
||||
await runGit(root, ["init"]);
|
||||
if (options.withRemote) {
|
||||
await runGit(root, ["remote", "add", "origin", "https://github.com/example/repo.git"]);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
async function expectWorkspaceValidationFailure(
|
||||
input: WorkspaceValidationInput,
|
||||
reason: string,
|
||||
@@ -377,6 +399,72 @@ describe("assertGitSensitiveAdapterWorkspaceValid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertPushCapabilityCheckoutValid", () => {
|
||||
it("rejects a GitHub PR workflow checkout without a configured push remote", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: false });
|
||||
try {
|
||||
await expect(assertPushCapabilityCheckoutValid({
|
||||
enabled: true,
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
},
|
||||
cwd,
|
||||
})).rejects.toMatchObject({
|
||||
code: "workspace_validation_failed",
|
||||
message: expect.stringContaining("has no configured push remote"),
|
||||
resultJson: {
|
||||
workspaceValidation: expect.objectContaining({
|
||||
reason: "missing_git_push_remote",
|
||||
issueId: "issue-1",
|
||||
executionWorkspaceCwd: cwd,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a GitHub PR workflow checkout when a push remote is configured", async () => {
|
||||
const cwd = await createGitCheckout({ withRemote: true });
|
||||
try {
|
||||
await expect(assertPushCapabilityCheckoutValid({
|
||||
enabled: true,
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
},
|
||||
cwd,
|
||||
})).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresPushCapabilityPreflight", () => {
|
||||
it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => {
|
||||
expect(requiresPushCapabilityPreflight({
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-1",
|
||||
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
||||
})).toBe(true);
|
||||
|
||||
expect(requiresPushCapabilityPreflight({
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-1",
|
||||
explicitRunScopedSkillKeys: [],
|
||||
})).toBe(false);
|
||||
|
||||
expect(requiresPushCapabilityPreflight({
|
||||
adapterType: "cursor-cloud",
|
||||
issueId: "issue-1",
|
||||
explicitRunScopedSkillKeys: ["paperclipai/bundled/software-development/github-pr-workflow"],
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => {
|
||||
it("removes only the host-side provision command for sandbox-backed low-trust runs", () => {
|
||||
const config = {
|
||||
|
||||
@@ -61,12 +61,13 @@ async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs =
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
|
||||
async function deleteHeartbeatRunsAfterActivityLogDrains(db: Db) {
|
||||
async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) {
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
await db.delete(activityLog);
|
||||
try {
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -527,8 +528,7 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await deleteHeartbeatRunsAfterActivityLogDrains(db);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
|
||||
@@ -260,6 +260,9 @@ const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
|
||||
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
|
||||
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
|
||||
const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
|
||||
const GITHUB_PR_WORKFLOW_SKILL_KEY = "paperclipai/bundled/software-development/github-pr-workflow";
|
||||
const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow";
|
||||
const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const;
|
||||
// Keep this in sync with local adapters that require a git workspace before launch.
|
||||
const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
@@ -411,6 +414,34 @@ function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string
|
||||
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
|
||||
function isConfiguredEnvBindingValue(binding: unknown) {
|
||||
const parsed = envBindingSchema.safeParse(binding);
|
||||
if (!parsed.success) return false;
|
||||
const value = parsed.data;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
if (value.type === "plain") return value.value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasGithubPrWorkflowSkill(desiredSkills: string[]) {
|
||||
return desiredSkills.some((skill) => {
|
||||
const normalized = skill.trim();
|
||||
return normalized === GITHUB_PR_WORKFLOW_SKILL_KEY
|
||||
|| normalized === GITHUB_PR_WORKFLOW_SKILL_SLUG
|
||||
|| normalized.endsWith(`/${GITHUB_PR_WORKFLOW_SKILL_SLUG}`);
|
||||
});
|
||||
}
|
||||
|
||||
export function requiresPushCapabilityPreflight(input: {
|
||||
adapterType: string;
|
||||
issueId: string | null | undefined;
|
||||
explicitRunScopedSkillKeys: string[];
|
||||
}) {
|
||||
return Boolean(input.issueId)
|
||||
&& GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType)
|
||||
&& hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys);
|
||||
}
|
||||
|
||||
const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
@@ -466,11 +497,18 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
routineEnv?: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
trustPreset?: TrustPresetResolution;
|
||||
requiredScopedEnvBinding?: {
|
||||
keys: string[];
|
||||
consumerScopes: Array<"agent" | "project">;
|
||||
reason: string;
|
||||
remediation: string;
|
||||
};
|
||||
}) {
|
||||
const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig);
|
||||
const environmentEnv = stripPaperclipRuntimeEnvBindings(input.environmentEnv);
|
||||
const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv);
|
||||
const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv);
|
||||
const agentEnv = parseObject(executionRunConfig.env);
|
||||
const lowTrustAllowedBindingIds = input.trustPreset?.kind === "low_trust_review"
|
||||
? input.trustPreset.boundary.allowedSecretBindingIds ?? []
|
||||
: undefined;
|
||||
@@ -480,6 +518,31 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
assertLowTrustEnvConfigAllowed(projectEnv, "project.env");
|
||||
assertLowTrustEnvConfigAllowed(routineEnv, "routine.env");
|
||||
}
|
||||
const requiredScopedEnvBinding = input.requiredScopedEnvBinding ?? null;
|
||||
const requiredScopedBindingsConfigured = requiredScopedEnvBinding
|
||||
? requiredScopedEnvBinding.keys.some((key) => (
|
||||
requiredScopedEnvBinding.consumerScopes.includes("agent")
|
||||
&& isConfiguredEnvBindingValue(agentEnv[key])
|
||||
) || (
|
||||
requiredScopedEnvBinding.consumerScopes.includes("project")
|
||||
&& isConfiguredEnvBindingValue(projectEnv?.[key])
|
||||
))
|
||||
: false;
|
||||
if (requiredScopedEnvBinding && !requiredScopedBindingsConfigured) {
|
||||
throw new ConfigurationIncompleteFailure(`configuration incomplete: ${requiredScopedEnvBinding.remediation}`, {
|
||||
configurationIncomplete: {
|
||||
reason: requiredScopedEnvBinding.reason,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
routineId: input.routineId ?? null,
|
||||
requiredEnvKeys: requiredScopedEnvBinding.keys,
|
||||
requiredScopes: requiredScopedEnvBinding.consumerScopes,
|
||||
missingBindings: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
// Pre-dispatch binding-validation gate: detect declared secret refs that have
|
||||
// no binding before resolving any secret value. Missing bindings short-circuit
|
||||
// to a configuration-incomplete blocker routed to a human owner instead of a
|
||||
@@ -522,6 +585,33 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
)),
|
||||
);
|
||||
}
|
||||
if (requiredScopedEnvBinding) {
|
||||
const requiredEnvKeys = new Set(requiredScopedEnvBinding.keys);
|
||||
const requiredScopes = new Set(requiredScopedEnvBinding.consumerScopes);
|
||||
const requiredMissingBindings = missingBindings.filter((binding) =>
|
||||
requiredScopes.has(binding.consumerType as "agent" | "project")
|
||||
&& requiredEnvKeys.has(binding.envKey),
|
||||
);
|
||||
if (requiredMissingBindings.length > 0) {
|
||||
const detail = requiredMissingBindings.map(formatMissingBindingForOperator).join("; ");
|
||||
throw new ConfigurationIncompleteFailure(
|
||||
`configuration incomplete: ${requiredScopedEnvBinding.remediation}; ${detail}`,
|
||||
{
|
||||
configurationIncomplete: {
|
||||
reason: requiredScopedEnvBinding.reason,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
routineId: input.routineId ?? null,
|
||||
requiredEnvKeys: requiredScopedEnvBinding.keys,
|
||||
requiredScopes: requiredScopedEnvBinding.consumerScopes,
|
||||
missingBindings: requiredMissingBindings,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (missingBindings.length > 0) {
|
||||
const detail = missingBindings.map(formatMissingBindingForOperator).join("; ");
|
||||
throw new ConfigurationIncompleteFailure(`configuration incomplete: ${detail}`, {
|
||||
@@ -1080,6 +1170,53 @@ function sameResolvedPath(left: string | null | undefined, right: string | null
|
||||
return path.resolve(leftPath) === path.resolve(rightPath);
|
||||
}
|
||||
|
||||
async function hasGitPushRemote(cwd: string | null | undefined) {
|
||||
const normalized = readNonEmptyString(cwd);
|
||||
if (!normalized) return false;
|
||||
const remoteNames = await execFile("git", ["remote"], { cwd: normalized })
|
||||
.then((result) =>
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
for (const remoteName of remoteNames) {
|
||||
const pushUrl = await execFile("git", ["remote", "get-url", "--push", remoteName], { cwd: normalized })
|
||||
.then((result) => readNonEmptyString(result.stdout))
|
||||
.catch(() => null);
|
||||
if (pushUrl) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function assertPushCapabilityCheckoutValid(input: {
|
||||
enabled: boolean;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
} | null;
|
||||
cwd: string | null | undefined;
|
||||
}) {
|
||||
if (!input.enabled || !input.issue) return;
|
||||
const cwd = readNonEmptyString(input.cwd);
|
||||
if (!cwd) return;
|
||||
if (await hasGitPushRemote(cwd)) return;
|
||||
throw new WorkspaceValidationFailure(
|
||||
`Issue ${input.issue.identifier ?? input.issue.id} requested the GitHub PR workflow, but checkout "${cwd}" has no configured push remote. Bind the run to a writable repo checkout before dispatching the agent.`,
|
||||
{
|
||||
workspaceValidation: {
|
||||
reason: "missing_git_push_remote",
|
||||
issueId: input.issue.id,
|
||||
issueIdentifier: input.issue.identifier,
|
||||
executionWorkspaceCwd: cwd,
|
||||
requiredEnvKeys: [...PUSH_CAPABILITY_ENV_KEYS],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertGitSensitiveAdapterWorkspaceValid(input: {
|
||||
adapterType: string;
|
||||
agentId: string;
|
||||
@@ -8631,6 +8768,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: selectedEnvironmentId
|
||||
? await environmentsSvc.getById(selectedEnvironmentId)
|
||||
: null;
|
||||
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId,
|
||||
});
|
||||
const pushCapabilityPreflightRequired = requiresPushCapabilityPreflight({
|
||||
adapterType: agent.adapterType,
|
||||
issueId,
|
||||
explicitRunScopedSkillKeys: runScopedMentionedSkillKeys,
|
||||
});
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
@@ -8645,6 +8792,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
routineEnv: routineEnvContext.env,
|
||||
secretsSvc,
|
||||
trustPreset,
|
||||
requiredScopedEnvBinding: pushCapabilityPreflightRequired
|
||||
? {
|
||||
keys: [...PUSH_CAPABILITY_ENV_KEYS],
|
||||
consumerScopes: ["agent", "project"],
|
||||
reason: "push_write_credential_missing",
|
||||
remediation:
|
||||
"GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.",
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
@@ -8653,11 +8809,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
} else {
|
||||
delete context.paperclipSecrets;
|
||||
}
|
||||
const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId,
|
||||
});
|
||||
const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
|
||||
resolvedConfig,
|
||||
runScopedMentionedSkillKeys,
|
||||
@@ -9271,6 +9422,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
environmentDriver: selectedEnvironment.driver,
|
||||
leaseMetadata: activeEnvironmentLease.lease.metadata,
|
||||
});
|
||||
await assertPushCapabilityCheckoutValid({
|
||||
enabled: pushCapabilityPreflightRequired && executionTarget?.kind === "local",
|
||||
issue: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
}
|
||||
: null,
|
||||
cwd: executionWorkspace.cwd,
|
||||
});
|
||||
const adapterEnv = Object.fromEntries(
|
||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
|
||||
Reference in New Issue
Block a user