fix: start tailscaled in Docker image and preflight tailscale daemon readiness

Tailscale remote access failed in the container with a bare "process exited 1":
the image ships the `tailscale` CLI but nothing ever ran `tailscaled`, so the
`tailscale funnel <port>` spawn died instantly on "failed to connect to local
tailscaled".

- Add scripts/docker-entrypoint.sh, which best-effort starts tailscaled in
  userspace-networking mode (needs neither NET_ADMIN nor /dev/net/tun, so the
  documented `docker run` is unchanged) and then execs the CLI with CMD verbatim.
  Opt out with FUSION_DISABLE_TAILSCALED=1.
- Symlink /var/lib/tailscale into /home/node/.tailscale so the documented
  `-v <vol>:/home/node` mount persists the node login across container recreates,
  and pre-create the daemon's socket/log paths node-owned before the USER switch.
- Preflight daemon reachability and backend state with `tailscale status --json`
  in evaluateRemoteLifecycle instead of only `which tailscale`, so unreachable,
  logged-out, and stopped backends all report an actionable
  runtime_prerequisite_missing reason rather than an unexplained exit 1.

Regression coverage asserts the invariant across all three unusable-backend
surfaces, not just the reported container repro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-22 19:09:41 -07:00
parent d71aa0d189
commit 3edb843f9b
5 changed files with 277 additions and 3 deletions

View File

@@ -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 <port>` 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.

View File

@@ -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 <vol>:/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"]

View File

@@ -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 <bin>` 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,

View File

@@ -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 <port>` 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";

34
scripts/docker-entrypoint.sh Executable file
View File

@@ -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
# <port>`, 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 <vol>:/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 "$@"