Repository navigation
fix: bound remote agent execution and secure demo state #395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** Quote interpolations as single POSIX shell arguments, including Git bearer credentials. */ | ||
| function sh(parts: TemplateStringsArray, ...values: unknown[]): string { | ||
| return parts.reduce((command, part, index) => { | ||
| const arg = index < values.length | ||
| ? `'${String(values[index]).replaceAll("'", "'\\''")}'` | ||
| : ""; | ||
| return command + part + arg; | ||
| }, ""); | ||
| } | ||
|
|
||
| export interface GitIntegrationInput { | ||
| baseRemote: string; | ||
| forkRemote: string; | ||
| baseCommit: string; | ||
| headCommit: string; | ||
| branch: string; | ||
| baseToken: string; | ||
| forkToken: string; | ||
| } | ||
|
|
||
| export interface GitCommandResult { exitCode: number; stdout: string } | ||
|
|
||
| /** An isolated, one-shot Git operation. The checkout is always removed, even on failure. */ | ||
| export async function integrateGit( | ||
| input: GitIntegrationInput, | ||
| dir: string, | ||
| run: (command: string) => Promise<GitCommandResult>, | ||
| ): Promise<{ status: "merged" | "conflicted"; commit?: string }> { | ||
| if (![input.baseCommit, input.headCommit].every((sha) => /^[a-f0-9]{40}$/.test(sha))) { | ||
| throw new Error("Invalid proposal commit"); | ||
| } | ||
| if (!/^\/workspace\/integration-[a-f0-9-]+$/.test(dir) && !/^\/tmp\/agent-integration-[a-f0-9-]+$/.test(dir)) { | ||
| throw new Error("Invalid integration directory"); | ||
| } | ||
| const baseAuth = `http.extraHeader=Authorization: Bearer ${input.baseToken}`; | ||
| const forkAuth = `http.extraHeader=Authorization: Bearer ${input.forkToken}`; | ||
| const checked = async (command: string): Promise<string> => { | ||
| const result = await run(command); | ||
| if (result.exitCode !== 0) throw new Error("Git integration command failed"); | ||
| return result.stdout.trim(); | ||
| }; | ||
|
|
||
| try { | ||
| await checked(sh`git -c ${baseAuth} clone ${input.baseRemote} ${dir}`); | ||
| const current = await checked(sh`git -C ${dir} rev-parse HEAD`); | ||
| if (current !== input.baseCommit) throw new Error("Project baseline has changed"); | ||
|
|
||
| await checked(sh`git -C ${dir} -c ${forkAuth} fetch ${input.forkRemote} ${input.branch}`); | ||
| const applied = await run(sh`git -C ${dir} -c ${"user.name=DevSpace Integrator"} -c ${"user.email=merge@devspace.invalid"} cherry-pick ${input.headCommit}`); | ||
| if (applied.exitCode !== 0) { | ||
| const conflicts = await checked(sh`git -C ${dir} diff --name-only --diff-filter=U`); | ||
| if (conflicts) return { status: "conflicted" }; | ||
| throw new Error("Git integration command failed"); | ||
| } | ||
|
|
||
| const commit = await checked(sh`git -C ${dir} rev-parse HEAD`); | ||
| await checked(sh`git -C ${dir} -c ${baseAuth} push origin ${`HEAD:${input.branch}`}`); | ||
| return { status: "merged", commit }; | ||
| } finally { | ||
| // Never let a leftover checkout turn a transient failure into a permanent one. | ||
| try { await run(sh`rm -rf -- ${dir}`); } catch { /* Best-effort cleanup. */ } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,12 @@ | ||
| import { DurableObject } from "cloudflare:workers"; | ||
| import type { Project, Task } from "./domain.js"; | ||
| import { InputError, type Project, type Task } from "./domain.js"; | ||
|
|
||
| interface Comparison { | ||
| prompts: [string, string]; | ||
| taskIds: [string, string]; | ||
| phase: "preparing" | "ready"; | ||
| leaseUntil: number; | ||
| } | ||
|
|
||
| /** The per-project Durable Object is the authority for task and proposal metadata. */ | ||
| export class ProjectCoordinator extends DurableObject { | ||
|
|
@@ -15,8 +22,50 @@ export class ProjectCoordinator extends DurableObject { | |
| return (await this.ctx.storage.get<Project>("project")) ?? null; | ||
| } | ||
|
|
||
| /** Stable task IDs make a failed second fork resumable without launching a third agent. */ | ||
| async beginComparison(prompts: [string, string], newTaskIds: [string, string]): Promise< | ||
| { state: "busy" | "ready" | "claimed"; taskIds: [string, string] } | ||
| > { | ||
| return this.ctx.storage.transaction(async (storage) => { | ||
| const current = await storage.get<Comparison>("comparison"); | ||
| if (current && current.prompts.some((p, index) => p !== prompts[index])) { | ||
| throw new InputError("This project already has a different comparison"); | ||
| } | ||
| if (current?.phase === "ready") return { state: "ready", taskIds: current.taskIds }; | ||
| if (current && current.leaseUntil > Date.now()) return { state: "busy", taskIds: current.taskIds }; | ||
| const taskIds = current?.taskIds ?? newTaskIds; | ||
| await storage.put("comparison", { | ||
| prompts, taskIds, phase: "preparing", leaseUntil: Date.now() + 120_000, | ||
| } satisfies Comparison); | ||
| return { state: "claimed", taskIds }; | ||
| }); | ||
| } | ||
|
|
||
| async finishComparison(success: boolean): Promise<void> { | ||
| await this.ctx.storage.transaction(async (storage) => { | ||
| const current = await storage.get<Comparison>("comparison"); | ||
| if (!current) return; | ||
| await storage.put("comparison", { | ||
| ...current, phase: success ? "ready" : "preparing", leaseUntil: 0, | ||
| } satisfies Comparison); | ||
|
Comment on lines
+44
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Make
To fix this, add a claim token to 🔒 Proposed fix interface Comparison {
prompts: [string, string];
taskIds: [string, string];
phase: "preparing" | "ready";
leaseUntil: number;
+ claimId?: string;
}
@@
- async finishComparison(success: boolean): Promise<void> {
+ async finishComparison(claimId: string, success: boolean): Promise<void> {
await this.ctx.storage.transaction(async (storage) => {
const current = await storage.get<Comparison>("comparison");
- if (!current) return;
+ if (!current || current.claimId !== claimId) return;In 🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
|
|
||
| async claimIntegration(): Promise<boolean> { | ||
| return this.ctx.storage.transaction(async (storage) => { | ||
| const lease = await storage.get<number>("integration:lease"); | ||
| if (lease && lease > Date.now()) return false; | ||
| await storage.put("integration:lease", Date.now() + 10 * 60_000); | ||
| return true; | ||
| }); | ||
| } | ||
|
|
||
| async releaseIntegration(): Promise<void> { | ||
| await this.ctx.storage.delete("integration:lease"); | ||
| } | ||
|
|
||
| async addTask(task: Task): Promise<void> { | ||
| if (await this.ctx.storage.get(`task:${task.id}`)) throw new Error("Duplicate task"); | ||
| if (await this.ctx.storage.get(`task:${task.id}`)) return; | ||
| await this.ctx.storage.put(`task:${task.id}`, task); | ||
| } | ||
|
|
||
|
|
@@ -29,7 +78,7 @@ export class ProjectCoordinator extends DurableObject { | |
| } | ||
|
|
||
| async tasks(): Promise<Task[]> { | ||
| const entries = await this.ctx.storage.list<Task>({ prefix: "task:", limit: 20 }); | ||
| const entries = await this.ctx.storage.list<Task>({ prefix: "task:", limit: 2 }); | ||
| return [...entries.values()]; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Waishnav/devspace
Length of output: 24354
🏁 Script executed:
Repository: Waishnav/devspace
Length of output: 40727
Document workspace persistence, not execution continuity.
TaskAgent.start()persists only thestartedflag. The model messages, deadline, and tool-call count remain local toexecute(). No resume state or restart path exists. State that workspace files persist across container replacement, but interrupted model loops do not recover autonomously.Suggested README edit
📝 Committable suggestion
🤖 Prompt for AI Agents