Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ coverage/
# cli-backend agent sandboxes — runtime scratch only (keep the dir + its README)
/workspaces/*
!/workspaces/README.md

# working-code workspace — cloned target repos agents inspect/modify (ENG-013); never committed
/workspace/
33 changes: 32 additions & 1 deletion apps/server/src/temporal/activities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assess, draft, proposeFileChanges, proposeTickets } from "@eng/agents"
import { assess, draft, ensureRepoCloned, proposeFileChanges, proposeTickets } from "@eng/agents"
import {
type DeliveryAdapter,
type DeployState,
Expand Down Expand Up @@ -213,6 +213,36 @@ export async function implementTicket(ticketId: string): Promise<PullRequestRef
// Central budget enforcement (invariant #3): remaining = limit − spent from the budgets table.
const remaining = (await getBudgetRemaining(role.id)) ?? role.monthlyBudgetCents

// ENG-013/ENG-001: ensure the target repo is cloned into the working-code workspace (idempotent),
// then point the coding agent at it so it reads/edits real source. No-op (audited) without GitHub.
let workdir: string | undefined
try {
const clone = await ensureRepoCloned()
if (clone) {
workdir = clone.path
await persistence.audit.append({
actor: "staff_engineer",
kind: "repo_context_ready",
ticketId,
payload: { path: clone.path, action: clone.action },
})
} else {
await persistence.audit.append({
actor: "system",
kind: "repo_context_skipped",
ticketId,
payload: { reason: "GitHub not configured" },
})
}
} catch (err) {
await persistence.audit.append({
actor: "system",
kind: "repo_context_skipped",
ticketId,
payload: { error: String(err) },
})
}

let proposed: Awaited<ReturnType<typeof proposeFileChanges>> | null = null
try {
proposed = await proposeFileChanges({
Expand All @@ -223,6 +253,7 @@ export async function implementTicket(ticketId: string): Promise<PullRequestRef
? `Implement ticket "${ticket.title}": ${ticket.description || "(no description provided)"}.`
: `Implement ticket ${ticketId}.`,
budgetCentsRemaining: remaining,
workdir,
})
await addSpend(role.id, proposed.costCents)
await persistence.audit.append({
Expand Down
45 changes: 45 additions & 0 deletions packages/agents/src/backends/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ describe("sandboxEnv", () => {
const env = sandboxEnv({ PATH: "/usr/bin" })
expect("HOME" in env).toBe(false)
})

it("forwards a PM-scoped GitHub token only for the PM role (ENG-013)", () => {
const src = { PATH: "/usr/bin", AGENT_PM_GITHUB_TOKEN: "pmtok" }
expect(sandboxEnv(src, "pm").GITHUB_TOKEN).toBe("pmtok")
expect(sandboxEnv(src, "pm").GH_TOKEN).toBe("pmtok")
expect(sandboxEnv(src, "staff_engineer").GITHUB_TOKEN).toBeUndefined()
expect(sandboxEnv(src).GITHUB_TOKEN).toBeUndefined()
})

it("never forwards the host GITHUB_TOKEN, even for the PM", () => {
const env = sandboxEnv({ PATH: "/usr/bin", GITHUB_TOKEN: "host" }, "pm")
expect(env.GITHUB_TOKEN).toBeUndefined()
})
})

describe("CliBackend sandbox enforcement", () => {
Expand Down Expand Up @@ -107,3 +120,35 @@ describe("CliBackend sandbox enforcement", () => {
expect(result.summary).not.toBe(process.cwd())
})
})

describe("CliBackend repo-aware workdir (ENG-001)", () => {
let dir: string
let bin: string
beforeEach(async () => {
dir = join(tmpdir(), `eng-code-${process.pid}-${Date.now()}`)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, "TARGET.txt"), "seeded-content")
bin = join(dir, "fakebin.sh")
await writeFile(bin, "#!/bin/sh\ncat TARGET.txt\n") // reads a file from its cwd (the clone)
await chmod(bin, 0o755)
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})

it("runs the agent in the provided workdir so it reads real source, and leaves it intact", async () => {
const backend = new CliBackend("claude-opus-4-8", { bin })
const result = await backend.run({
role: "staff_engineer",
systemPrompt: "",
tools: [],
goalContext: "",
task: "x",
budgetCentsRemaining: 100,
workdir: dir,
})
expect(result.summary).toContain("seeded-content") // the agent saw the seeded file
expect(existsSync(dir)).toBe(true) // a provided workdir is persistent — not cleaned up
expect(existsSync(join(dir, "TARGET.txt"))).toBe(true)
})
})
40 changes: 32 additions & 8 deletions packages/agents/src/backends/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,16 @@ const SANDBOX_ENV_ALLOWLIST = [
* Build the scrubbed environment for a sandboxed agent run: the allow-list above, plus any names
* explicitly opted in via `AGENT_SANDBOX_ENV_PASSTHROUGH` (comma-separated). Everything else — notably
* credentials — is withheld. Pure and side-effect-free for testability.
*
* Scoped-token exception (ENG-013): the PM role provisions repos with `git`/`gh`, so — and only for
* the PM — a dedicated `AGENT_PM_GITHUB_TOKEN` is forwarded as `GITHUB_TOKEN`/`GH_TOKEN`. This is a
* deliberately narrow widening of the ENG-005 boundary: no other role's sandbox receives git/gh
* credentials, and the host's full `GITHUB_TOKEN` is never forwarded.
*/
export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
export function sandboxEnv(
source: NodeJS.ProcessEnv = process.env,
role?: string,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const key of SANDBOX_ENV_ALLOWLIST) {
const value = source[key]
Expand All @@ -83,6 +91,10 @@ export function sandboxEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proc
if (value !== undefined) env[key] = value
}
}
if (role === "pm" && source.AGENT_PM_GITHUB_TOKEN) {
env.GITHUB_TOKEN = source.AGENT_PM_GITHUB_TOKEN
env.GH_TOKEN = source.AGENT_PM_GITHUB_TOKEN
}
return env
}

Expand Down Expand Up @@ -120,13 +132,19 @@ export class CliBackend implements WorkerBackend {
buildSystemPrompt(input),
]

// Confine the agent's file/bash tools to a throwaway sandbox — never the repo source.
const sandbox = await createSandbox(this.workspaceRoot, input.role)
// ENG-001: when a workdir is provided (a cloned target repo in the working-code workspace) run
// there so the agent reads/edits real source; otherwise confine it to a throwaway agent-state
// sandbox — never this product's own source. Provided workdirs are persistent — don't clean up.
const useProvided = Boolean(input.workdir)
const cwd = input.workdir ?? (await createSandbox(this.workspaceRoot, input.role))
const env = sandboxEnv(process.env, input.role)
let stdout: string
try {
stdout = await this.exec(args, input.task, sandbox, signal)
stdout = await this.exec(args, input.task, cwd, env, signal)
} finally {
if (!process.env.AGENT_KEEP_WORKSPACE) await rm(sandbox, { recursive: true, force: true })
if (!useProvided && !process.env.AGENT_KEEP_WORKSPACE) {
await rm(cwd, { recursive: true, force: true })
}
}

let parsed: CliResult | undefined
Expand Down Expand Up @@ -154,10 +172,16 @@ export class CliBackend implements WorkerBackend {
}
}

private exec(args: string[], stdin: string, cwd: string, signal?: AbortSignal): Promise<string> {
private exec(
args: string[],
stdin: string,
cwd: string,
env: NodeJS.ProcessEnv,
signal?: AbortSignal,
): Promise<string> {
return new Promise((resolve, reject) => {
// Scrubbed env (ENG-005): the agent's tools never see host secrets.
const child = spawn(this.bin, args, { cwd, signal, env: sandboxEnv() })
// Scrubbed env (ENG-005); the PM also gets a scoped git/gh token (ENG-013). See sandboxEnv.
const child = spawn(this.bin, args, { cwd, signal, env })
let stdout = ""
let stderr = ""
child.stdout.on("data", (chunk) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ export {
parseProposal,
proposeFileChanges,
} from "./propose"
export {
type CloneResult,
ensureRepoCloned,
type RepoTarget,
repoTargetFromEnv,
} from "./provision"
export { PROPOSAL_SCHEMA, TICKETS_SCHEMA, VERDICT_SCHEMA } from "./schemas"
export { repoWorkspacePath, resolveCodeWorkspaceRoot } from "./workspace"

/** Factory for the default unit Worker runtime (mode from options or the WORKER_MODE env var). */
export function createWorker(options?: ClaudeWorkerOptions): Worker {
Expand Down
3 changes: 3 additions & 0 deletions packages/agents/src/propose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface ProposeChangesInput {
/** What to build. */
task: string
budgetCentsRemaining: number
/** Cloned target repo to work in (ENG-001) — the agent reads/edits real source there. */
workdir?: string
}

export interface ProposedChanges {
Expand Down Expand Up @@ -39,6 +41,7 @@ export async function proposeFileChanges(input: ProposeChangesInput): Promise<Pr
task: `${input.task}\n\n${CONTRACT}`,
budgetCentsRemaining: input.budgetCentsRemaining,
outputSchema: PROPOSAL_SCHEMA,
workdir: input.workdir,
})
const { summary, files } = parseProposal(result.summary)
return {
Expand Down
56 changes: 56 additions & 0 deletions packages/agents/src/provision.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest"
import { ensureRepoCloned, gitArgs, provisionAction, repoTargetFromEnv } from "./provision"

describe("repoTargetFromEnv", () => {
it("is null when GitHub isn't configured", () => {
expect(repoTargetFromEnv({})).toBeNull()
expect(repoTargetFromEnv({ GITHUB_OWNER: "acme" })).toBeNull()
})

it("reads owner/repo and prefers the PM-scoped token over the host token", () => {
expect(
repoTargetFromEnv({
GITHUB_OWNER: "acme",
GITHUB_REPO: "widget",
GITHUB_TOKEN: "host",
AGENT_PM_GITHUB_TOKEN: "scoped",
}),
).toEqual({ owner: "acme", repo: "widget", token: "scoped" })
})
})

describe("provisionAction", () => {
it("clones a fresh dir and pulls an existing checkout", () => {
expect(provisionAction("/no/such/dir-xyz")).toBe("clone")
// The repo root itself has a .git → would be a pull (proves the existsSync branch).
expect(provisionAction(process.cwd())).toBe("pull")
})
})

describe("gitArgs", () => {
it("builds a shallow clone with the auth header (token kept out of config)", () => {
const args = gitArgs("clone", "/code/acme/widget", {
owner: "acme",
repo: "widget",
token: "t0ken",
})
expect(args).toContain("clone")
expect(args).toContain("--depth")
expect(args.join(" ")).toContain("https://github.com/acme/widget.git")
expect(args.join(" ")).toContain("http.extraheader=AUTHORIZATION: bearer t0ken")
})

it("builds a ff-only pull for an existing checkout, no auth header when tokenless", () => {
const args = gitArgs("pull", "/code/acme/widget", { owner: "acme", repo: "widget" })
expect(args.slice(0, 2)).toEqual(["-C", "/code/acme/widget"])
expect(args).toContain("pull")
expect(args).toContain("--ff-only")
expect(args.join(" ")).not.toContain("extraheader")
})
})

describe("ensureRepoCloned", () => {
it("skips (returns null) when no target is configured", async () => {
await expect(ensureRepoCloned(null)).resolves.toBeNull()
})
})
74 changes: 74 additions & 0 deletions packages/agents/src/provision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import { dirname, join } from "node:path"
import { repoWorkspacePath } from "./workspace"

/**
* Repository provisioning (ENG-013): clone the target repo into the working-code workspace so coding
* agents (ENG-001) have real source to read and edit. The PM agent owns this via its granted `git`/`gh`
* tools; this module is the *deterministic backbone* the lifecycle calls to guarantee (and verify) the
* clone exists regardless of the agent turn — idempotent, and a no-op when GitHub isn't configured.
*/

export interface RepoTarget {
owner: string
repo: string
/** Scoped token for private clones; the PM-scoped token is preferred over the host delivery token. */
token?: string
}

/** Resolve the target repo from env (reuses the delivery config). Null when unconfigured (skip). */
export function repoTargetFromEnv(env: NodeJS.ProcessEnv = process.env): RepoTarget | null {
const owner = env.GITHUB_OWNER
const repo = env.GITHUB_REPO
if (!owner || !repo) return null
return { owner, repo, token: env.AGENT_PM_GITHUB_TOKEN ?? env.GITHUB_TOKEN }
}

export type ProvisionAction = "clone" | "pull"

/** Pure idempotency decision: an existing checkout is pulled, otherwise cloned fresh. */
export function provisionAction(dir: string): ProvisionAction {
return existsSync(join(dir, ".git")) ? "pull" : "clone"
}

export interface CloneResult {
path: string
action: ProvisionAction
}

/** `git` args for the action — token (if any) injected via http.extraheader so it never lands in
* `.git/config`. Pure, for testing. */
export function gitArgs(action: ProvisionAction, dir: string, target: RepoTarget): string[] {
const auth = target.token ? ["-c", `http.extraheader=AUTHORIZATION: bearer ${target.token}`] : []
if (action === "pull") return ["-C", dir, ...auth, "pull", "--ff-only"]
const url = `https://github.com/${target.owner}/${target.repo}.git`
return [...auth, "clone", "--depth", "1", url, dir]
}

function runGit(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn("git", args, { stdio: "ignore" })
child.on("error", reject)
child.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`git exited with code ${code}`)),
)
})
}

/**
* Ensure the target repo is cloned into the working-code workspace, idempotently. Returns the clone
* path + the action taken, or `null` when GitHub isn't configured (caller audits the skip). Operates
* only under the working-code workspace — never this product's own tree.
*/
export async function ensureRepoCloned(
target: RepoTarget | null = repoTargetFromEnv(),
): Promise<CloneResult | null> {
if (!target) return null
const dir = repoWorkspacePath(target.owner, target.repo)
const action = provisionAction(dir)
if (action === "clone") await mkdir(dirname(dir), { recursive: true })
await runGit(gitArgs(action, dir, target))
return { path: dir, action }
}
28 changes: 28 additions & 0 deletions packages/agents/src/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from "vitest"
import { repoWorkspacePath, resolveCodeWorkspaceRoot } from "./workspace"

describe("resolveCodeWorkspaceRoot", () => {
const prev = process.env.AGENT_CODE_WORKSPACE
afterEach(() => {
if (prev === undefined) delete process.env.AGENT_CODE_WORKSPACE
else process.env.AGENT_CODE_WORKSPACE = prev
})

it("honors AGENT_CODE_WORKSPACE", () => {
expect(resolveCodeWorkspaceRoot({ AGENT_CODE_WORKSPACE: "/tmp/code-ws" })).toBe("/tmp/code-ws")
})

it("defaults to a top-level workspace/ (singular, distinct from throwaway workspaces/)", () => {
const root = resolveCodeWorkspaceRoot({})
expect(root.endsWith("/workspace")).toBe(true)
expect(root.endsWith("/workspaces")).toBe(false)
})
})

describe("repoWorkspacePath", () => {
it("lays out clones as <root>/<owner>/<repo>", () => {
expect(repoWorkspacePath("acme", "widget", { AGENT_CODE_WORKSPACE: "/code" })).toBe(
"/code/acme/widget",
)
})
})
Loading