Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ WORKDIR /openclaw

# Pin to a known-good ref (tag/branch). Override in Railway template settings if needed.
# Using a released tag avoids build breakage when `main` temporarily references unpublished packages.
ARG OPENCLAW_GIT_REF=v2026.3.8
ARG OPENCLAW_GIT_REF=v2026.3.28
RUN git clone --depth 1 --branch "${OPENCLAW_GIT_REF}" https://github.com/openclaw/openclaw.git .

# Patch: relax version requirements for packages that may reference unpublished versions.
Expand All @@ -46,9 +46,11 @@ ENV NODE_ENV=production
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tini \
python3 \
python3-venv \
&& curl -fsSL https://tailscale.com/install.sh | sh \
Comment on lines 50 to +53

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This installs Tailscale via curl ... | sh, which is a supply-chain risk and makes builds non-reproducible (script contents can change). Prefer using the official apt repo with pinned package versions (or verifying the script checksum/signature) so the image build is deterministic and safer.

Suggested change
tini \
python3 \
python3-venv \
&& curl -fsSL https://tailscale.com/install.sh | sh \
apt-transport-https \
tini \
python3 \
python3-venv \
&& curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg -o /usr/share/keyrings/tailscale-archive-keyring.gpg \
&& curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list -o /etc/apt/sources.list.d/tailscale.list \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tailscale \

Copilot uses AI. Check for mistakes.
&& rm -rf /var/lib/apt/lists/*

# `openclaw update` expects pnpm. Provide it in the runtime image.
Expand Down
51 changes: 35 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# OpenClaw Railway Template (1‑click deploy)
# OpenClaw Railway Template + Tailscale (1‑click deploy)

Fork of [vignesh07/clawdbot-railway-template](https://github.com/vignesh07/clawdbot-railway-template) that adds **optional Tailscale integration** so you can access your OpenClaw instance over a private tailnet instead of (or in addition to) the public Railway domain.

This repo packages **OpenClaw** for Railway with a small **/setup** web wizard so users can deploy and onboard **without running any commands**.

Expand All @@ -9,13 +11,15 @@ This repo packages **OpenClaw** for Railway with a small **/setup** web wizard s
- Persistent state via **Railway Volume** (so config/credentials/memory survive redeploys)
- One-click **Export backup** (so users can migrate off Railway later)
- **Import backup** from `/setup` (advanced recovery)
- **Optional Tailscale access** — expose the gateway on your private tailnet via HTTPS (e.g. `https://<hostname>.<tailnet>.ts.net`)

## How it works (high level)

- The container runs a wrapper web server.
- The wrapper protects `/setup` (and the Control UI at `/openclaw`) with `SETUP_PASSWORD` using HTTP Basic auth.
- During setup, the wrapper runs `openclaw onboard --non-interactive ...` inside the container, writes state to the volume, and then starts the gateway.
- After setup, **`/` is OpenClaw**. The wrapper reverse-proxies all traffic (including WebSockets) to the local gateway process.
- If `TS_AUTHKEY` is set, the wrapper runs `tailscale up` at boot and `tailscale serve --https=443` after the gateway is ready, proxying Tailscale HTTPS traffic to the wrapper (which injects the gateway auth token automatically).

## Railway deploy instructions (what you’ll publish as a Template)

Expand All @@ -35,6 +39,10 @@ Recommended:
Optional:
- `OPENCLAW_GATEWAY_TOKEN` — if not set, the wrapper generates one (not ideal). In a template, set it using a generated secret.

Tailscale (optional):
- `TS_AUTHKEY` — a Tailscale auth key ([generate one here](https://login.tailscale.com/admin/settings/keys)). **Use a reusable key** so the node survives redeploys.
- `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to the container hostname if not set.

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs mismatch: code defaults TS_HOSTNAME to "openclaw-railway" (src/server.js), not the container hostname. Update the README to match the actual default, or update the code to use the container hostname (e.g. os.hostname()) if that’s the intended behavior.

Suggested change
- `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to the container hostname if not set.
- `TS_HOSTNAME` — the machine name on your tailnet (e.g. `openclaw`). Defaults to `openclaw-railway` if not set.

Copilot uses AI. Check for mistakes.

Notes:
- This template pins OpenClaw to a released version by default via Docker build arg `OPENCLAW_GIT_REF` (override if you want `main`).

Expand All @@ -48,9 +56,32 @@ Then:
- Complete setup
- Visit `https://<your-app>.up.railway.app/` and `/openclaw` (same Basic auth)

## Tailscale access (optional)

If you set `TS_AUTHKEY` (and optionally `TS_HOSTNAME`), the wrapper will:

1. Run `tailscale up --authkey=<key> --hostname=<name>` on container start.
2. After the gateway becomes ready, run `tailscale serve --bg --https=443 http://127.0.0.1:<PORT>` to expose the wrapper over HTTPS on your tailnet.
3. Add the Tailscale DNS name (e.g. `https://openclaw.tail1234.ts.net`) to the gateway's `allowedOrigins` so the Control UI works.

Once deployed, access the Control UI at:
```
https://<TS_HOSTNAME>.<tailnet>.ts.net
```

The Tailscale URL does **not** require HTTP Basic auth — access is controlled by your tailnet ACLs. The wrapper still injects the gateway Bearer token automatically.

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README claims “The Tailscale URL does not require HTTP Basic auth”, but the wrapper’s requireDashboardAuth enforces Basic auth for all routes (except /healthz, /setup/healthz, /hooks) regardless of whether traffic comes via Tailscale. Either update the documentation to state Basic auth still applies, or add logic to bypass Basic auth for authenticated Tailscale connections (e.g. based on Tailscale Serve identity headers).

Suggested change
The Tailscale URL does **not** require HTTP Basic auth — access is controlled by your tailnet ACLs. The wrapper still injects the gateway Bearer token automatically.
The Tailscale URL is still protected by the wrapper's HTTP Basic auth. Tailscale provides private network access controlled by your tailnet ACLs, and the wrapper still injects the gateway Bearer token automatically.

Copilot uses AI. Check for mistakes.

### Tailscale tips

- **Use a reusable auth key.** Single-use keys are revoked after first use and will fail on redeploy.
- The auth key must come from the same tailnet as the devices you want to access the instance from.
- Check `https://<your-app>.up.railway.app/healthz` to confirm Tailscale status and gateway readiness.
- If Tailscale auth fails, the wrapper logs the error but continues running — the public Railway URL still works.

## Support / community

- GitHub Issues: https://github.com/vignesh07/clawdbot-railway-template/issues
- Upstream repo: https://github.com/vignesh07/clawdbot-railway-template
- This fork: https://github.com/ABFS-Inc/clawdbot-railway-template
- Discord: https://discord.com/invite/clawd

If you’re filing a bug, please include the output of:
Expand Down Expand Up @@ -175,18 +206,6 @@ docker run --rm -p 8080:8080 \

---

## Official template / endorsements

- Officially recommended by OpenClaw: <https://docs.openclaw.ai/railway>
- Railway announcement (official): [Railway tweet announcing 1‑click OpenClaw deploy](https://x.com/railway/status/2015534958925013438)

![Railway official tweet screenshot](assets/railway-official-tweet.jpg)

- Endorsement from Railway CEO: [Jake Cooper tweet endorsing the OpenClaw Railway template](https://x.com/justjake/status/2015536083514405182)

![Jake Cooper endorsement tweet screenshot](assets/railway-ceo-endorsement.jpg)

- Created and maintained by **Vignesh N (@vignesh07)**
- **11000+ deploys on Railway and counting** [Link to template on Railway](https://railway.com/deploy/clawdbot-railway-template)
## Credits

![Railway template deploy count](assets/railway-deploys.jpg)
Based on the [OpenClaw Railway Template](https://github.com/vignesh07/clawdbot-railway-template) created by **Vignesh N (@vignesh07)**. This fork adds optional Tailscale integration only.
153 changes: 137 additions & 16 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,111 @@ function isConfigured() {
}
}

async function syncAllowedOrigins() {
const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`];

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncAllowedOrigins() seeds localhost origins using INTERNAL_GATEWAY_PORT, but the browser origin will be the wrapper’s public PORT (e.g. http://localhost:8080). This will likely block the Control UI locally (and in any environment where RAILWAY_PUBLIC_DOMAIN/TS origins aren’t present) because the gateway will reject the Origin header. Use PORT for wrapper origins (http://localhost:${PORT}, http://127.0.0.1:${PORT}) and only include INTERNAL_GATEWAY_PORT if you explicitly expect direct browser access to the internal gateway port.

Suggested change
const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`];
const origins = [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`];

Copilot uses AI. Check for mistakes.
if (process.env.RAILWAY_PUBLIC_DOMAIN) origins.push(`https://${process.env.RAILWAY_PUBLIC_DOMAIN}`);
try {
const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output);
const dns = (ts?.Self?.DNSName || "").replace(/\.$/, "");
if (dns) {
// HTTPS only for the FQDN — tailscale serve provides a valid cert for it.
origins.push(`https://${dns}`);
origins.push(`http://${dns}:${PORT}`);
// Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames.
const shortName = dns.split(".")[0];
if (shortName) {
origins.push(`http://${shortName}:${PORT}`);
}
}
// Tailscale IPs. HTTP only — no TLS cert for raw IP addresses.
for (const ip of ts?.Self?.TailscaleIPs || []) {
const bracket = ip.includes(":");
origins.push(bracket ? `http://[${ip}]:${PORT}` : `http://${ip}:${PORT}`);
}
} catch (err) {
console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`);
}
console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`);
await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "--json", "gateway.controlUi.allowedOrigins", JSON.stringify(origins)])).catch(() => {});
Comment on lines +114 to +139

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior updates gateway.controlUi.allowedOrigins (syncAllowedOrigins), but there’s no regression test analogous to test/trusted-proxies-config.test.js. Given the repo’s pattern of source-level tests, consider adding a small test that asserts the server writes gateway.controlUi.allowedOrigins so future refactors don’t drop it silently.

Suggested change
async function syncAllowedOrigins() {
const origins = [`http://localhost:${INTERNAL_GATEWAY_PORT}`, `http://127.0.0.1:${INTERNAL_GATEWAY_PORT}`];
if (process.env.RAILWAY_PUBLIC_DOMAIN) origins.push(`https://${process.env.RAILWAY_PUBLIC_DOMAIN}`);
try {
const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output);
const dns = (ts?.Self?.DNSName || "").replace(/\.$/, "");
if (dns) {
// HTTPS only for the FQDN — tailscale serve provides a valid cert for it.
origins.push(`https://${dns}`);
origins.push(`http://${dns}:${PORT}`);
// Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames.
const shortName = dns.split(".")[0];
if (shortName) {
origins.push(`http://${shortName}:${PORT}`);
}
}
// Tailscale IPs. HTTP only — no TLS cert for raw IP addresses.
for (const ip of ts?.Self?.TailscaleIPs || []) {
const bracket = ip.includes(":");
origins.push(bracket ? `http://[${ip}]:${PORT}` : `http://${ip}:${PORT}`);
}
} catch (err) {
console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`);
}
console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`);
await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "--json", "gateway.controlUi.allowedOrigins", JSON.stringify(origins)])).catch(() => {});
export const CONTROL_UI_ALLOWED_ORIGINS_CONFIG_KEY = "gateway.controlUi.allowedOrigins";
export function buildAllowedOrigins(
ts,
{
internalGatewayPort = INTERNAL_GATEWAY_PORT,
port = PORT,
railwayPublicDomain = process.env.RAILWAY_PUBLIC_DOMAIN,
} = {},
) {
const origins = [`http://localhost:${internalGatewayPort}`, `http://127.0.0.1:${internalGatewayPort}`];
if (railwayPublicDomain) origins.push(`https://${railwayPublicDomain}`);
const dns = (ts?.Self?.DNSName || "").replace(/\.$/, "");
if (dns) {
// HTTPS only for the FQDN — tailscale serve provides a valid cert for it.
origins.push(`https://${dns}`);
origins.push(`http://${dns}:${port}`);
// Short hostname (MagicDNS alias, e.g. "athena"). HTTP only — no TLS cert for bare hostnames.
const shortName = dns.split(".")[0];
if (shortName) {
origins.push(`http://${shortName}:${port}`);
}
}
// Tailscale IPs. HTTP only — no TLS cert for raw IP addresses.
for (const ip of ts?.Self?.TailscaleIPs || []) {
const bracket = ip.includes(":");
origins.push(bracket ? `http://[${ip}]:${port}` : `http://${ip}:${port}`);
}
return origins;
}
async function syncAllowedOrigins() {
let origins = buildAllowedOrigins();
try {
const ts = JSON.parse((await runCmd("tailscale", ["status", "--json"])).output);
origins = buildAllowedOrigins(ts);
} catch (err) {
console.warn(`[wrapper] syncAllowedOrigins: tailscale status failed: ${String(err)}`);
}
console.log(`[wrapper] syncAllowedOrigins: ${JSON.stringify(origins)}`);
await runCmd(
OPENCLAW_NODE,
clawArgs(["config", "set", "--json", CONTROL_UI_ALLOWED_ORIGINS_CONFIG_KEY, JSON.stringify(origins)]),
).catch(() => {});

Copilot uses AI. Check for mistakes.
}

function debugPrefix(value) {
const text = String(value || "").trim();
if (!text) return "(missing)";
return `${text.slice(0, 10)}... (len=${text.length})`;
}

function debugSnippet(text, max = 400) {
const cleaned = String(text || "").trim();
if (!cleaned) return "(no output)";
return cleaned.length > max ? `${cleaned.slice(0, max)}...` : cleaned;
}

let tailscaleUpDone = false;
let tailscaleServeDone = false;

async function ensureTailscaleBoot() {
if (!process.env.TS_AUTHKEY) {
console.log("[wrapper] Tailscale bootstrap skipped: TS_AUTHKEY not set");
return { ok: false, reason: "TS_AUTHKEY not set" };
}
if (tailscaleUpDone) return { ok: true };

try {
const hostname = process.env.TS_HOSTNAME?.trim() || "openclaw-railway";
console.log(`[wrapper] Tailscale bootstrap: auth=${debugPrefix(process.env.TS_AUTHKEY)} hostname=${debugPrefix(hostname)}`);
childProcess.spawn("tailscaled", ["--tun=userspace-networking", "--statedir=/data/tailscale"], { stdio: "ignore" });
await sleep(3000);
Comment on lines +154 to +168

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureTailscaleBoot() is not concurrency-safe: the guard is only tailscaleUpDone, so concurrent callers (e.g. the boot-time await ensureTailscaleBoot() and a request-triggered ensureGatewayRunning→ensureTailscaleServe) can both spawn tailscaled and race tailscale up. Consider adding a shared in-flight promise (like gatewayStarting) to serialize bootstrapping and ensure only one tailscaled process is started.

Copilot uses AI. Check for mistakes.

const up = await runCmd("tailscale", ["up", `--authkey=${process.env.TS_AUTHKEY}`, `--hostname=${hostname}`]);
console.log(`[wrapper] tailscale up exit=${up.code} output=${debugSnippet(up.output)}`);
if (up.code !== 0) {
throw new Error(`tailscale up failed (code=${up.code}): ${debugSnippet(up.output)}`);
}

const status = await runCmd("tailscale", ["status", "--json"]);
console.log(`[wrapper] tailscale status exit=${status.code} output=${debugSnippet(status.output)}`);

tailscaleUpDone = true;
console.log(`[wrapper] Tailscale authenticated (hostname=${hostname})`);
return { ok: true };
} catch (err) {
console.warn(`[wrapper] Tailscale bootstrap failed: ${String(err)}`);
return { ok: false, reason: String(err) };
Comment on lines +176 to +184

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureTailscaleBoot(): a failure to run/parse tailscale status --json is treated as a bootstrap failure even if tailscale up already succeeded. That can cause repeated tailscale up retries on subsequent calls and potentially multiple tailscaled instances. Consider making the status call best-effort (log but don’t fail), or at least gate JSON.parse on status.code === 0.

Copilot uses AI. Check for mistakes.
}
}

async function ensureTailscaleServe() {
if (!process.env.TS_AUTHKEY) return { ok: false, reason: "TS_AUTHKEY not set" };
if (tailscaleServeDone) return { ok: true };

const started = await ensureTailscaleBoot();
if (!started.ok) return started;

try {
const serve = await runCmd("tailscale", ["serve", "--bg", "--https=443", `http://127.0.0.1:${PORT}`]);
console.log(`[wrapper] tailscale serve exit=${serve.code} output=${debugSnippet(serve.output)}`);
if (serve.code !== 0) {
throw new Error(`tailscale serve failed (code=${serve.code}): ${debugSnippet(serve.output)}`);
}

const status = await runCmd("tailscale", ["status", "--json"]);
console.log(`[wrapper] tailscale status after serve exit=${status.code} output=${debugSnippet(status.output)}`);
const ts = JSON.parse(status.output);
const dns = (ts?.Self?.DNSName || "").replace(/\.$/, "");
if (dns && isConfigured()) {
await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "gateway.auth.allowTailscale", "true"]));
console.log(`[wrapper] Tailscale up: https://${dns}`);
}

tailscaleServeDone = true;
return { ok: true };
} catch (err) {
console.warn(`[wrapper] Tailscale serve failed: ${String(err)}`);
return { ok: false, reason: String(err) };
}
Comment on lines +202 to +216

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureTailscaleServe(): after successfully running tailscale serve, the subsequent tailscale status --json + JSON.parse can throw (nonzero exit, non-JSON output) and the function will return ok:false without setting tailscaleServeDone. This can lead to repeated tailscale serve attempts and noisy logs even though serve is already configured. Treat the status fetch/parse as best-effort, or check status.code before parsing and still set tailscaleServeDone when serve succeeded.

Copilot uses AI. Check for mistakes.
}

// One-time migration: rename legacy config files to openclaw.json so existing
// deployments that still have the old filename on their volume keep working.
(function migrateLegacyConfigFile() {
Expand Down Expand Up @@ -150,22 +255,23 @@ function sleep(ms) {
async function waitForGatewayReady(opts = {}) {
const timeoutMs = opts.timeoutMs ?? 20_000;
const start = Date.now();
// Use a TCP connect probe: the gateway speaks WebSocket, not plain HTTP,
// so fetch() always throws. A successful TCP handshake is enough to know
// the port is open and the gateway is accepting connections.
const net = await import("node:net");
while (Date.now() - start < timeoutMs) {
try {
// Try the default Control UI base path, then fall back to root.
const paths = ["/openclaw", "/"];
for (const p of paths) {
try {
const res = await fetch(`${GATEWAY_TARGET}${p}`, { method: "GET" });
// Any HTTP response means the port is open.
if (res) return true;
} catch {
// try next
}
}
} catch {
// not ready
}
const ok = await new Promise((resolve) => {
const sock = net.createConnection({
host: INTERNAL_GATEWAY_HOST,
port: INTERNAL_GATEWAY_PORT,
timeout: 500,
});
const done = (v) => { try { sock.destroy(); } catch {} resolve(v); };
sock.on("connect", () => done(true));
sock.on("timeout", () => done(false));
sock.on("error", () => done(false));
});
if (ok) return true;
await sleep(250);
}
return false;
Expand Down Expand Up @@ -242,6 +348,9 @@ async function ensureGatewayRunning() {
if (!ready) {
throw new Error("Gateway did not become ready in time");
}

// If Tailscale is enabled, expose the gateway only after it's ready.
await ensureTailscaleServe();
} catch (err) {
const msg = `[gateway] start failure: ${String(err)}`;
lastGatewayError = msg;
Expand Down Expand Up @@ -755,6 +864,8 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
clawArgs(["config", "set", "--json", "gateway.trustedProxies", JSON.stringify(["127.0.0.1"]) ]),
);

await syncAllowedOrigins();

// Optional: configure a custom OpenAI-compatible provider (base URL) for advanced users.
if (payload.customProviderId?.trim() && payload.customProviderBaseUrl?.trim()) {
const providerId = payload.customProviderId.trim();
Expand Down Expand Up @@ -1355,8 +1466,11 @@ function requireDashboardAuth(req, res, next) {
// The gateway is only reachable from this container. The Control UI in the browser
// cannot set custom Authorization headers for WebSocket connections, so we inject
// the token into proxied requests at the wrapper level.
// Always overwrite any incoming Authorization header (e.g. browser's cached Basic auth
// credentials set by requireDashboardAuth) with the gateway Bearer token. The gateway
// only accepts Bearer auth; Basic auth is already validated upstream by requireDashboardAuth.
function attachGatewayAuthHeader(req) {
if (!req?.headers?.authorization && OPENCLAW_GATEWAY_TOKEN) {
if (OPENCLAW_GATEWAY_TOKEN) {
req.headers.authorization = `Bearer ${OPENCLAW_GATEWAY_TOKEN}`;
}
}
Expand Down Expand Up @@ -1406,10 +1520,16 @@ const server = app.listen(PORT, "0.0.0.0", async () => {

console.log(`[wrapper] gateway token: ${OPENCLAW_GATEWAY_TOKEN ? "(set)" : "(missing)"}`);
console.log(`[wrapper] gateway target: ${GATEWAY_TARGET}`);
console.log(`[wrapper] TS_AUTHKEY: ${debugPrefix(process.env.TS_AUTHKEY)}`);
console.log(`[wrapper] TS_HOSTNAME: ${debugPrefix(process.env.TS_HOSTNAME)}`);
Comment on lines +1523 to +1524

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper logs a prefix/length for TS_AUTHKEY. Even partial auth keys can be sensitive and may be retained in Railway logs; consider logging only whether the variable is set (or redacting the entire value) to avoid leaking credentials into logs.

Suggested change
console.log(`[wrapper] TS_AUTHKEY: ${debugPrefix(process.env.TS_AUTHKEY)}`);
console.log(`[wrapper] TS_HOSTNAME: ${debugPrefix(process.env.TS_HOSTNAME)}`);
console.log(`[wrapper] TS_AUTHKEY: ${process.env.TS_AUTHKEY ? "(set)" : "(missing)"}`);
console.log(`[wrapper] TS_HOSTNAME: ${process.env.TS_HOSTNAME ? "(set)" : "(missing)"}`);

Copilot uses AI. Check for mistakes.
if (!SETUP_PASSWORD) {
console.warn("[wrapper] WARNING: SETUP_PASSWORD is not set; /setup will error.");
}

// Start/auth Tailscale as early as possible (independent of OpenClaw config).
// This ensures the machine appears in the tailnet on first boot.
await ensureTailscaleBoot();

// Optional operator hook to install/persist extra tools under /data.
// This is intentionally best-effort and should be used to set up persistent
// prefixes (npm/pnpm/python venv), not to mutate the base image.
Expand Down Expand Up @@ -1452,6 +1572,7 @@ const server = app.listen(PORT, "0.0.0.0", async () => {
console.log("[wrapper] config detected; starting gateway...");
try {
await ensureGatewayRunning();
await syncAllowedOrigins();
console.log("[wrapper] gateway ready");
} catch (err) {
console.error(`[wrapper] gateway failed to start at boot: ${String(err)}`);
Expand Down
Loading