Upgrade to v0.39.0: reapply fork patches on clean upstream base
Some checks failed
PR Checks / Lint (push) Has been cancelled
PR Checks / Typecheck (push) Has been cancelled
PR Checks / Build (push) Has been cancelled
PR Checks / Test shard 1/4 (push) Has been cancelled
PR Checks / Test shard 2/4 (push) Has been cancelled
PR Checks / Test shard 3/4 (push) Has been cancelled
PR Checks / Test shard 4/4 (push) Has been cancelled

Strategy: reset main to upstream/main (v0.39.0) and reapply only the
fork-specific patches that survive — most older patches (plugin-loader
routing, heartbeat resolver, autoload, cross-spawn, scope-resolution
fixes) were absorbed by upstream between v0.28.1 → v0.39.0.

Surviving patches:
- Dockerfile: keep single-stage Coolify-compatible build (upstream went
  multi-stage which breaks our bind-mount strategy for
  /home/s/.codex, /home/s/.claude, /usr/lib/node_modules)
- packages/engine/src/sandbox-tester-gate.ts: shared Phase-3 pre-merge
  gate module (Opus 4.7 + OpenSandbox + Playwright smoke)
- packages/engine/src/executor.ts: call runSandboxTesterGate from
  finalizeAlreadyReviewedTask recovery path
- packages/engine/src/project-engine.ts: call applySandboxTesterGate
  from primary direct-merge dispatch (the path that actually fires in
  prod — recovery path only catches stuck tasks)
- packages/dashboard/src/auth-middleware.ts: registerPluginExemptPath
  for webhook endpoints (Grafana, Gitea) that must bypass daemon-token
- packages/dashboard/src/plugin-routes.ts: auto-register plugin-defined
  routes as daemon-exempt so external plugins (telemetry-watcher,
  gitea-issues) work out of the box

Tested locally: pnpm install + pnpm -r build green, no TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
semih
2026-06-03 18:41:04 +03:00
parent 04a5cd196c
commit 511f930d20
6 changed files with 251 additions and 66 deletions

View File

@@ -1,88 +1,40 @@
# syntax=docker/dockerfile:1
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends git build-essential python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/cli/package.json ./packages/cli/package.json
COPY packages/cli-alias/package.json ./packages/cli-alias/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/dashboard/package.json ./packages/dashboard/package.json
COPY packages/desktop/package.json ./packages/desktop/package.json
COPY packages/droid-cli/package.json ./packages/droid-cli/package.json
COPY packages/engine/package.json ./packages/engine/package.json
COPY packages/mobile/package.json ./packages/mobile/package.json
COPY packages/pi-claude-cli/package.json ./packages/pi-claude-cli/package.json
COPY packages/pi-llama-cpp/package.json ./packages/pi-llama-cpp/package.json
COPY packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY plugins/examples/fusion-plugin-auto-label/package.json ./plugins/examples/fusion-plugin-auto-label/package.json
COPY plugins/examples/fusion-plugin-ci-status/package.json ./plugins/examples/fusion-plugin-ci-status/package.json
COPY plugins/examples/fusion-plugin-notification/package.json ./plugins/examples/fusion-plugin-notification/package.json
COPY plugins/examples/fusion-plugin-settings-demo/package.json ./plugins/examples/fusion-plugin-settings-demo/package.json
COPY plugins/fusion-plugin-paperclip-runtime/package.json ./plugins/fusion-plugin-paperclip-runtime/package.json
COPY plugins/fusion-plugin-dependency-graph/package.json ./plugins/fusion-plugin-dependency-graph/package.json
COPY plugins/fusion-plugin-cli-printing-press/package.json ./plugins/fusion-plugin-cli-printing-press/package.json
COPY plugins/fusion-plugin-openclaw-runtime/package.json ./plugins/fusion-plugin-openclaw-runtime/package.json
COPY plugins/fusion-plugin-hermes-runtime/package.json ./plugins/fusion-plugin-hermes-runtime/package.json
COPY plugins/fusion-plugin-droid-runtime/package.json ./plugins/fusion-plugin-droid-runtime/package.json
COPY plugins/fusion-plugin-cursor-runtime/package.json ./plugins/fusion-plugin-cursor-runtime/package.json
COPY plugins/fusion-plugin-agent-browser/package.json ./plugins/fusion-plugin-agent-browser/package.json
COPY plugins/fusion-plugin-whatsapp-chat/package.json ./plugins/fusion-plugin-whatsapp-chat/package.json
COPY plugins/fusion-plugin-roadmap/package.json ./plugins/fusion-plugin-roadmap/package.json
COPY plugins/fusion-plugin-even-realities-glasses/package.json ./plugins/fusion-plugin-even-realities-glasses/package.json
COPY plugins/fusion-plugin-reports/package.json ./plugins/fusion-plugin-reports/package.json
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/gsxdsm/fusion"
FROM node:22-slim
LABEL org.opencontainers.image.source="https://github.com/Runfusion/Fusion"
LABEL org.opencontainers.image.description="AI-orchestrated task board"
ENV NODE_ENV=production
ENV PORT=4040
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& apt-get install -y --no-install-recommends git build-essential python3 curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /project
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/cli/package.json ./packages/cli/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/dashboard/package.json ./packages/dashboard/package.json
COPY packages/engine/package.json ./packages/engine/package.json
COPY packages ./packages
COPY plugins ./plugins
RUN pnpm install --frozen-lockfile --prod \
--filter @runfusion/fusion
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/packages/core/dist ./packages/core/dist
COPY --from=builder /app/packages/engine/dist ./packages/engine/dist
COPY --from=builder /app/packages/dashboard/dist ./packages/dashboard/dist
COPY --from=builder /app/packages/cli/dist ./packages/cli/dist
COPY . .
RUN pnpm build
# @runfusion/fusion references typebox at runtime via the bundled CLI.
COPY --from=builder /app/node_modules/.pnpm/typebox@*/node_modules/typebox /project/node_modules/typebox
RUN mkdir -p /project && chown -R node:node /project /app
RUN chown node:node /project
RUN ln -s /usr/lib/node_modules/@openai/codex/bin/codex.js /usr/local/bin/codex
USER node
WORKDIR /project
EXPOSE 4040
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://localhost:4040/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS -o /dev/null http://localhost:4040/ || exit 1
ENTRYPOINT ["node", "packages/cli/dist/bin.js"]
ENTRYPOINT ["node", "/app/packages/cli/dist/bin.js"]
CMD ["dashboard", "--host", "0.0.0.0"]

View File

@@ -19,6 +19,24 @@ export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* Plugin-registered paths that should bypass daemon-token auth.
* Used for webhook endpoints (Grafana, Gitea, GitHub, etc.) that need to be
* reachable from external services without holding the daemon token.
*/
const PLUGIN_EXEMPT_PATHS = new Set<string>();
/**
* Register a webhook path defined by a plugin so daemon auth lets it through.
* Paths must start with `/api/plugins/`. Idempotent.
*/
export function registerPluginExemptPath(path: string): void {
if (!path.startsWith("/api/plugins/")) {
throw new Error(`registerPluginExemptPath: path must start with /api/plugins/ (got: ${path})`);
}
PLUGIN_EXEMPT_PATHS.add(path);
}
/**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,
* /assets/*, favicon, etc.) must load unauthenticated so the frontend JS can
@@ -66,7 +84,13 @@ export function getDaemonToken(options?: { daemon?: { token: string }; noAuth?:
* Check if a request path is exempt from authentication.
*/
function isExemptPath(path: string): boolean {
return EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"));
if (EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"))) {
return true;
}
for (const exempt of PLUGIN_EXEMPT_PATHS) {
if (path === exempt || path.startsWith(exempt + "/")) return true;
}
return false;
}
/**

View File

@@ -32,6 +32,7 @@ import {
notFound,
} from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
import { registerPluginExemptPath } from "./auth-middleware.js";
// PluginRunner interface for optional plugin runner
@@ -627,6 +628,15 @@ export function createPluginRouter(
router.delete(fullPath, handler);
break;
}
// Webhook endpoints (Grafana, Gitea, GitHub) must be reachable without the
// daemon token. Plugin-defined routes handle their own auth via webhook
// secrets in plugin settings; bypass daemon auth here.
try {
registerPluginExemptPath(`/api/plugins${fullPath}`);
} catch {
// ignore — non-/api/plugins paths are rejected; safe to skip.
}
}
}

View File

@@ -44,6 +44,7 @@ import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { resolveSandboxBackend } from "./sandbox/index.js";
import type { SandboxBackend } from "./sandbox/types.js";
import { runSandboxTesterGate } from "./sandbox-tester-gate.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@earendil-works/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, detectNestedWorktreeRoot, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js";
@@ -1298,10 +1299,24 @@ export class TaskExecutor {
return "blocked";
}
const gateResult = await runSandboxTesterGate(this.rootDir, latestTask, executorLog);
if (gateResult.verdict === "REVISE") {
await this.store.logEntry(
taskId,
"Sandbox-tester REVISE — merge blocked, bouncing to in-progress",
gateResult.report.slice(0, 4000),
this.getRunContextFor(taskId),
);
await this.store.moveTask(taskId, "in-progress");
return "blocked";
}
await this.store.logEntry(
taskId,
"Task already in-review after completion — finalizing merge",
undefined,
gateResult.verdict === "APPROVE"
? "Sandbox-tester APPROVE — finalizing merge"
: "Task already in-review after completion — finalizing merge",
gateResult.verdict === "APPROVE" ? gateResult.report.slice(0, 2000) : undefined,
this.getRunContextFor(taskId),
);
await this.store.mergeTask(taskId);

View File

@@ -27,6 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js";
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
import { runAiMerge } from "./merger-ai.js";
import { applySandboxTesterGate } from "./sandbox-tester-gate.js";
import { promoteBranchGroup } from "./group-merge-coordinator.js";
import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js";
@@ -1916,6 +1917,32 @@ export class ProjectEngine {
// Direct merge via AI agent, gated by semaphore
runtimeLog.log(`${manualResolver ? "Manual" : "Auto"}-merge merging ${taskId}...`);
// Sandbox-tester pre-merge gate (opt-in via FUSION_SANDBOX_GATE_ENABLED=true)
// — see packages/engine/src/sandbox-tester-gate.ts. Returns false on REVISE,
// which moves the task back to in-progress and aborts this merge attempt.
try {
const gateTask = await store.getTask(taskId).catch(() => null);
if (gateTask) {
const proceed = await applySandboxTesterGate(store, cwd, gateTask, runtimeLog);
if (!proceed) {
runtimeLog.log(`Sandbox-tester REVISE — merge aborted for ${taskId}, bounced to in-progress`);
if (manualResolver) {
this.manualMergeResolvers.delete(taskId);
manualResolver.resolve({
task: gateTask,
branch: gateTask.branch ?? "",
merged: false,
worktreeRemoved: false,
branchDeleted: false,
} as MergeResult);
}
continue;
}
}
} catch (gateErr) {
runtimeLog.warn(`Sandbox-tester gate threw for ${taskId}: ${gateErr instanceof Error ? gateErr.message : String(gateErr)} — proceeding with merge`);
}
const semaphore = (this.runtime as any).globalSemaphore;
const pool = (this.runtime as any).worktreePool;

View File

@@ -0,0 +1,157 @@
import { exec } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, writeFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Task, TaskStore } from "@fusion/core";
const execAsync = promisify(exec);
export type SandboxTesterVerdict = "APPROVE" | "REVISE" | "SKIP";
export interface SandboxTesterResult {
verdict: SandboxTesterVerdict;
report: string;
}
/**
* Pre-merge sandbox-tester gate.
*
* Spawns an Opus 4.7 Claude subprocess with the `.fusion/agents/sandbox-tester/instructions.md`
* persona and an MCP-attached OpenSandbox connection (configured in the host's ~/.claude.json).
* The persona's responsibility: spin a sandbox, boot the worktree's dev server, run Playwright
* smokes against acceptance criteria, emit a verdict ending with `Sandbox verdict: APPROVE | REVISE | SKIP`.
*
* Gate is OPT-IN via env var `FUSION_SANDBOX_GATE_ENABLED=true`. Disabled by default so existing
* tasks merge through the normal path. Errors and timeouts default to APPROVE to avoid blocking
* on infrastructure flakes — caller logs the error trail for investigation.
*
* @param rootDir Project root containing `.fusion/agents/sandbox-tester/instructions.md`
* @param task The task object (used for id, title, description, worktree path)
* @param logger Optional logger for executor-side trace output
*/
export async function runSandboxTesterGate(
rootDir: string,
task: Task,
logger?: { log: (msg: string) => void; warn: (msg: string) => void },
): Promise<SandboxTesterResult> {
const log = logger ?? { log: (m: string) => console.log(`[sandbox-gate] ${m}`), warn: (m: string) => console.warn(`[sandbox-gate] ${m}`) };
if (process.env.FUSION_SANDBOX_GATE_ENABLED !== "true") {
return { verdict: "SKIP", report: "FUSION_SANDBOX_GATE_ENABLED is not set" };
}
const personaPath = join(rootDir, ".fusion/agents/sandbox-tester/instructions.md");
if (!existsSync(personaPath)) {
log.warn(`Sandbox-tester persona missing at ${personaPath} — defaulting to APPROVE`);
return { verdict: "APPROVE", report: `persona missing at ${personaPath}` };
}
const worktreePath = task.worktree;
if (!worktreePath || !existsSync(worktreePath)) {
return { verdict: "APPROVE", report: "no worktree available for sandbox testing" };
}
let changedFiles = "";
try {
const { stdout } = await execAsync(`git -C "${worktreePath}" diff --name-only origin/dev...HEAD`, {
timeout: 10000,
});
changedFiles = stdout.trim();
} catch (err) {
changedFiles = `(diff failed: ${err instanceof Error ? err.message : String(err)})`;
}
let persona: string;
try {
persona = await readFile(personaPath, "utf-8");
} catch (err) {
log.warn(`Sandbox-tester persona read failed: ${err}; defaulting to APPROVE`);
return { verdict: "APPROVE", report: `persona read failed: ${err}` };
}
const promptFile = join("/tmp", `sandbox-tester-prompt-${task.id}-${Date.now()}.txt`);
await writeFile(promptFile, persona);
const userPrompt = [
`Task ID: ${task.id}`,
`Title: ${task.title ?? "(untitled)"}`,
"",
"Description:",
task.description ?? "(none)",
"",
`Worktree path: ${worktreePath}`,
"Changed files vs origin/dev:",
changedFiles || "(none detected)",
"",
"Run your standard sandbox-tester workflow. End your response with a line of the form `Sandbox verdict: APPROVE` or `Sandbox verdict: REVISE` or `Sandbox verdict: SKIP`.",
].join("\n");
const escapedPrompt = userPrompt.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "\\`").replace(/\$/g, "\\$");
const claudeCmd = `claude --print --model claude-opus-4-7 --permission-mode bypassPermissions --append-system-prompt "$(cat "${promptFile}")" "${escapedPrompt}"`;
const timeoutMs = Number(process.env.FUSION_SANDBOX_GATE_TIMEOUT_MS ?? "600000");
log.log(`Sandbox-tester gate ENGAGED for ${task.id} (timeout=${timeoutMs}ms)`);
let stdout = "";
try {
const result = await execAsync(claudeCmd, {
cwd: "/project",
timeout: timeoutMs,
maxBuffer: 16 * 1024 * 1024,
shell: "/bin/bash",
});
stdout = result.stdout;
} catch (err) {
log.warn(`Sandbox-tester gate FAILED for ${task.id}: ${err instanceof Error ? err.message : String(err)} — defaulting to APPROVE`);
return { verdict: "APPROVE", report: `gate error: ${err instanceof Error ? err.message : String(err)}` };
} finally {
try { await unlink(promptFile); } catch { /* ignore */ }
}
const match = stdout.match(/Sandbox verdict:\s*(APPROVE|REVISE|SKIP)/i);
if (!match) {
log.warn(`Sandbox-tester gate produced no parseable verdict for ${task.id} — defaulting to APPROVE`);
return { verdict: "APPROVE", report: `no verdict found in output (length=${stdout.length}):\n${stdout.slice(-2000)}` };
}
const verdict = match[1].toUpperCase() as SandboxTesterVerdict;
log.log(`Sandbox-tester gate verdict for ${task.id}: ${verdict}`);
return { verdict, report: stdout };
}
/**
* Convenience wrapper: runs the gate and applies side-effects on REVISE (bounce task back to
* in-progress, log the rejection). Returns true if merge should proceed, false if blocked.
*
* APPROVE / SKIP → returns true (proceed with merge). On APPROVE we log the truncated report.
* REVISE → moves the task back to in-progress, logs the smoke report, returns false.
*/
export async function applySandboxTesterGate(
store: TaskStore,
rootDir: string,
task: Task,
logger?: { log: (msg: string) => void; warn: (msg: string) => void },
): Promise<boolean> {
const result = await runSandboxTesterGate(rootDir, task, logger);
if (result.verdict === "REVISE") {
await store.logEntry(
task.id,
"Sandbox-tester REVISE — merge blocked, bouncing to in-progress",
result.report.slice(0, 4000),
);
await store.moveTask(task.id, "in-progress");
return false;
}
if (result.verdict === "APPROVE") {
await store.logEntry(
task.id,
"Sandbox-tester APPROVE — proceeding with merge",
result.report.slice(0, 2000),
);
}
// SKIP: no log noise (gate disabled or no smokeable surface)
return true;
}