diff --git a/.changeset/tailscale-daemon-container.md b/.changeset/tailscale-daemon-container.md new file mode 100644 index 0000000000..d76da16da4 --- /dev/null +++ b/.changeset/tailscale-daemon-container.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Tailscale remote access failing with "process exited 1" in the Docker image. +category: fix +dev: The image shipped the `tailscale` CLI but never ran `tailscaled`, so the `tailscale funnel ` spawn died immediately. A new `scripts/docker-entrypoint.sh` best-effort starts the daemon in userspace-networking mode (no NET_ADMIN/tun caps needed; disable with `FUSION_DISABLE_TAILSCALED=1`), and `/var/lib/tailscale` symlinks into `/home/node/.tailscale` so login state persists across container recreates. `evaluateRemoteLifecycle` now preflights daemon reachability and backend state via `tailscale status --json` instead of only `which tailscale`, so an unreachable, logged-out, or stopped backend reports an actionable `runtime_prerequisite_missing` reason. diff --git a/Dockerfile b/Dockerfile index 14f16303c0..6a1b32a8d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -162,6 +162,21 @@ RUN chown node:node /app \ && mkdir -p /workspace /home/node/.fusion \ && chown node:node /workspace /home/node/.fusion +# FNXC:DockerRun 2026-08-23-02:03: tailscaled runs as `node`, not root, so its default socket and +# state directories must exist node-owned BEFORE the USER switch — the daemon cannot mkdir them under +# root-owned /var/run and /var/lib itself. /var/lib/tailscale is a SYMLINK into /home/node/.tailscale +# rather than a real directory: the documented `-v :/home/node` mount then carries the node's +# login state, so an authenticated container survives `docker rm` + recreate instead of demanding a +# fresh `tailscale up` every rebuild. /var/log/tailscaled.log is pre-created for the same +# ownership reason. +RUN mkdir -p /var/run/tailscale /home/node/.tailscale \ + && rm -rf /var/lib/tailscale \ + && ln -sfn /home/node/.tailscale /var/lib/tailscale \ + && touch /var/log/tailscaled.log \ + && chown node:node /var/run/tailscale /home/node/.tailscale /var/log/tailscaled.log + +COPY --chmod=0755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + USER node # FNXC:DockerRun 2026-08-18-06:55: A DEFAULT GIT IDENTITY, because a container has none and Fusion @@ -189,5 +204,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ # FNXC:DockerRun 2026-07-23-00:00: Entrypoint uses the absolute app path so it works # regardless of the working directory or any volume mounted at /workspace. -ENTRYPOINT ["node", "/app/packages/cli/dist/bin.js"] +# FNXC:DockerRun 2026-08-23-02:03: The wrapper script best-effort starts tailscaled and then `exec`s +# that same absolute-path node invocation with CMD verbatim, so PID 1, signal handling, and every +# documented `docker run ... dashboard --host 0.0.0.0` argument list behave exactly as before. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["dashboard", "--host", "0.0.0.0"] diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 911612c629..221ff15037 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -445,21 +445,33 @@ beforeEach(() => { mocks.oauthRefreshSchedulerStart.mockClear(); mocks.oauthRefreshSchedulerStop.mockClear(); + /* + FNXC:EngineTests 2026-08-23-02:03: + The default exec seam answers TWO different probes now. `which ` still resolves to a stub path, + but the tailscale preflight additionally runs `tailscale status --json` and PARSES the result, so a + path string on that call reads as an unreadable daemon rather than a ready one. Branching on the + argv keeps "tools are present and healthy" as the suite-wide default; the prerequisite-failure cases + override this mock per test. + */ mocks.execFile.mockImplementation(( _file: string, _args: string[], _options: unknown, callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void, ) => { + const stdout = _args?.includes("status") && _args?.includes("--json") + ? JSON.stringify({ BackendState: "Running" }) + : "/usr/bin/mock\n"; + if (typeof _options === "function") { (_options as (error: Error | null, result: { stdout: string; stderr: string }) => void)(null, { - stdout: "/usr/bin/mock\n", + stdout, stderr: "", }); return {} as never; } - callback?.(null, { stdout: "/usr/bin/mock\n", stderr: "" }); + callback?.(null, { stdout, stderr: "" }); return {} as never; }); }); @@ -1373,6 +1385,94 @@ describe("ProjectEngine remote lifecycle quick tunnel mode", () => { await engine.stop(); }); + /* + FNXC:RemoteAccess 2026-08-23-02:03: + Surface enumeration for the tailscaled-readiness preflight. The reported symptom was ONE of these + (daemon absent in a container), but all three reach the tunnel spawn through the same path and all + three previously produced an unexplained "process exited 1", so the invariant under test is + "an unusable tailscale backend fails preflight with an actionable message", not the single repro. + Cases: daemon unreachable (exec fails, no stdout), logged out (non-zero exit but JSON on stdout — + the reason stdout is trusted over exit code), and stopped. + */ + const tailscaleSettings = () => ({ + ...baseSettings, + remoteAccess: { + ...baseRemoteAccess, + activeProvider: "tailscale" as const, + }, + }); + + const mockTailscaleStatus = ( + outcome: { error?: Error; stdout?: string; stderr?: string }, + ): void => { + mocks.execFile.mockImplementation(( + _file: string, + _args: string[], + _options: unknown, + callback?: (error: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const isStatusProbe = _args?.includes("status") && _args?.includes("--json"); + const error = isStatusProbe ? outcome.error ?? null : null; + const result = isStatusProbe + ? { stdout: outcome.stdout ?? "", stderr: outcome.stderr ?? "" } + : { stdout: "/usr/bin/mock\n", stderr: "" }; + + const done = typeof _options === "function" + ? _options as (error: Error | null, result: { stdout: string; stderr: string }) => void + : callback; + + // execFile's promisified form attaches stdout/stderr to the rejection, which is exactly how the + // logged-out case delivers its JSON; mirror that shape instead of a bare Error. + if (error) { + Object.assign(error, result); + } + done?.(error, result); + return {} as never; + }); + }; + + it("fails tailscale preflight with an actionable message when tailscaled is unreachable", async () => { + mockTailscaleStatus({ + error: new Error("exit 1"), + stderr: "failed to connect to local tailscaled; it doesn't appear to be running", + }); + mocks.currentStore = createMockStore(tailscaleSettings()).store; + + const engine = createEngine(); + await engine.start(); + await expect(engine.startRemoteTunnel()).rejects.toThrow( + /runtime_prerequisite_missing:tailscaled is not reachable: failed to connect to local tailscaled/, + ); + await engine.stop(); + }); + + it("fails tailscale preflight when the daemon runs but the node is logged out", async () => { + mockTailscaleStatus({ + error: new Error("exit 1"), + stdout: JSON.stringify({ BackendState: "NeedsLogin" }), + }); + mocks.currentStore = createMockStore(tailscaleSettings()).store; + + const engine = createEngine(); + await engine.start(); + await expect(engine.startRemoteTunnel()).rejects.toThrow( + /runtime_prerequisite_missing:Tailscale is not logged in/, + ); + await engine.stop(); + }); + + it("fails tailscale preflight when the backend is stopped", async () => { + mockTailscaleStatus({ stdout: JSON.stringify({ BackendState: "Stopped" }) }); + mocks.currentStore = createMockStore(tailscaleSettings()).store; + + const engine = createEngine(); + await engine.start(); + await expect(engine.startRemoteTunnel()).rejects.toThrow( + /runtime_prerequisite_missing:Tailscale is stopped/, + ); + await engine.stop(); + }); + it("keeps manual cloudflare validation unchanged when quick tunnel is disabled", async () => { const manualSettings = { ...baseSettings, diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index e1a84de512..9622a4692a 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -33,6 +33,9 @@ import { emitOverseerRetry, emitOverseerSteering, getTaskHardMergeBlocker, + PreMergeStepsNotRunError, + PRE_MERGE_STEPS_NOT_RUN_BLOCKER, + resolveRequiredPreMergeStepIds, isLiveSharedBranchGroupMemberIntegration, isSharedBranchGroupMemberIntegration, isWorkspaceTask, @@ -2897,6 +2900,24 @@ export class ProjectEngine { return { provider, reason: "runtime_prerequisite_missing", message: executable.message }; } + /* + FNXC:RemoteAccess 2026-08-23-02:03: + Binary presence is NOT readiness. `tailscale funnel ` is a thin client that talks to the + `tailscaled` daemon over a local socket, so a box with the CLI installed but no running daemon + (every slim container — the image ships the binary, the daemon is a separate process) fails the + instant it spawns: "failed to connect to local tailscaled", exit 1, no URL. Preflighting only + `which tailscale` let that reach the UI as a bare process-exited-1 with nothing actionable in it + (operator report). The same is true of a daemon that is running but logged out or stopped. + + Checking the backend state here converts all three into a named prerequisite failure carrying + the command that fixes it, on the same `runtime_prerequisite_missing` channel the missing-binary + case already uses — so no new UI state is needed to show it. + */ + const daemon = await this.checkTailscaleDaemonReady(); + if (!daemon.ready) { + return { provider, reason: "runtime_prerequisite_missing", message: daemon.message }; + } + return { provider, config: { @@ -2956,6 +2977,73 @@ export class ProjectEngine { }; } + /** + * FNXC:RemoteAccess 2026-08-23-02:03: + * Resolve whether `tailscaled` is reachable AND its backend is usable for a tunnel. + * + * `tailscale status --json` is the probe because it answers both questions in one call and, unlike + * the human-readable form, keeps printing parseable JSON while logged out — it merely exits + * non-zero. So a non-zero exit WITH stdout is a state answer, not a transport failure; only an + * empty stdout means the daemon could not be reached at all. The stderr first line is carried into + * the message because it is where the real cause lands ("it doesn't appear to be running"). + * + * Bounded by a short timeout: this runs on the tunnel-start path, and a wedged daemon socket must + * fail the preflight rather than hang the operator's click. + */ + private async checkTailscaleDaemonReady(): Promise<{ ready: boolean; message?: string }> { + let stdout = ""; + try { + const result = await execFileAsync("tailscale", ["status", "--json"], { + timeout: 5_000, + maxBuffer: 8 * 1024 * 1024, + }); + stdout = result.stdout ?? ""; + } catch (error) { + const failure = error as { stdout?: string; stderr?: string; message?: string }; + stdout = failure.stdout ?? ""; + if (!stdout.trim()) { + const detail = (failure.stderr ?? failure.message ?? "").trim().split("\n")[0] ?? ""; + return { + ready: false, + message: `tailscaled is not reachable${detail ? `: ${detail}` : ""}. Start the daemon before enabling the tunnel (in a container: tailscaled --tun=userspace-networking).`, + }; + } + } + + let backendState: string | undefined; + try { + backendState = (JSON.parse(stdout) as { BackendState?: string }).BackendState; + } catch { + return { + ready: false, + message: "tailscale status returned unreadable output, so tailscaled readiness could not be confirmed", + }; + } + + if (backendState === "Running") { + return { ready: true }; + } + + if (backendState === "NeedsLogin" || backendState === "NoState") { + return { + ready: false, + message: "Tailscale is not logged in — run `tailscale up` to authenticate this machine, then start the tunnel again.", + }; + } + + if (backendState === "Stopped") { + return { + ready: false, + message: "Tailscale is stopped — run `tailscale up` to bring this machine back online.", + }; + } + + return { + ready: false, + message: `Tailscale is not ready (backend state: ${backendState ?? "unknown"})`, + }; + } + private async checkExecutableAvailable(command: string): Promise<{ available: boolean; message?: string }> { const checker = process.platform === "win32" ? "where" : "which"; try { @@ -5008,6 +5096,33 @@ export class ProjectEngine { ); }); + /* + FNXC:RequiredPreMergeSteps 2026-08-22-22:40 (FN-9191 wedge): + A merge door that refused ONLY because an enabled pre-merge gate has not reported yet + is a NOT-YET answer, so it must not park the card. FN-9191: this sweep enqueued the + card ~2s after `fn_task_done` and ~18s before the graph started its own Code Review + node; the door refused correctly, the generic non-conflict branch below wrote + `status:"failed"`, and when Code Review APPROVED two minutes later every remaining + merge — including the graph's own merge node — died on `task is marked 'failed'`. + + Deferral semantics: no status write, no `mergeRetries` burn, no operator handoff. The + card stays merge-eligible and the admission filter in `enqueueEligibleInReviewTasks` + holds it out of the queue until the gate reports, so this cannot spin. + */ + if (err instanceof PreMergeStepsNotRunError) { + await store + .logEntry( + taskId, + `Merge deferred: ${PRE_MERGE_STEPS_NOT_RUN_BLOCKER} — waiting for the enabled pre-merge gate(s) to report (no retry consumed)`, + "MergeDeferredPendingPreMergeSteps", + ) + .catch(() => undefined); + if (hasManualResolver) { + this.rejectMergeResolvers(taskId, err); + } + continue; + } + // A manual policy-resume attempt must re-park through the same durable // handoff path; other manual merge failures still reject their caller. const isPolicyBlock = (err as { code?: unknown })?.code === "merge-blocked-by-policy"; diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000000..70597b9610 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# FNXC:DockerRun 2026-08-23-02:03: +# Start `tailscaled` before the dashboard, because the image shipping the `tailscale` CLI is not +# enough to make the remote-access feature work. Fusion's tunnel spawns a bare `tailscale funnel +# `, which needs a running daemon on the DEFAULT socket; with no daemon it dies instantly with +# "failed to connect to local tailscaled" and exit 1, surfacing in the UI as an unexplained process +# failure (operator report: "starting tailscale tunnel in container is failing with process exited 1"). +# +# Userspace networking (`--tun=userspace-networking`) is deliberate: it needs neither `NET_ADMIN` nor +# `/dev/net/tun`, so the documented `docker run` keeps working unchanged, and it is sufficient for +# `tailscale serve`/`funnel`, which proxy to a local port rather than route packets. The SOCKS5/HTTP +# proxy listeners are the standard userspace-mode escape hatch for outbound tailnet access, which has +# no route out otherwise. +# +# Startup is BEST-EFFORT and never fails the container: an operator who does not use Tailscale must +# still get a dashboard. Set FUSION_DISABLE_TAILSCALED=1 to skip it entirely. +# +# Login is NOT automated here — `tailscale up` requires an interactive auth URL or an operator's auth +# key, so the daemon comes up logged-out and the operator authenticates once. State lives under +# /var/lib/tailscale, which the image symlinks into /home/node/.tailscale so the documented +# `-v :/home/node` mount persists that login across container recreates. +set -e + +if [ "${FUSION_DISABLE_TAILSCALED:-0}" != "1" ] && [ -x /usr/sbin/tailscaled ]; then + if [ ! -S /var/run/tailscale/tailscaled.sock ]; then + /usr/sbin/tailscaled \ + --tun=userspace-networking \ + --socks5-server=localhost:1055 \ + --outbound-http-proxy-listen=localhost:1055 \ + >/var/log/tailscaled.log 2>&1 & + fi +fi + +exec node /app/packages/cli/dist/bin.js "$@"