replit について
Replit/Nix ワークスペースでツールやサービスを運用するためのスキル集です。永続化、ブラウザー、ネットワーク、システムライブラリー、デスクトップアプリなど、Replit 固有の制約に沿った手順とスクリプトを提供します。
主な機能
- 01Replit/Nix 環境のガイド
- 02永続化と環境変数の管理
- 03Nix のシステムライブラリー対応
- 04ブラウザー操作と自動化
- 05ポート公開とサービス設定
- 06ワークスペース間の接続手順
活用シーン
- 01再起動後もデータや環境設定を保持
- 02ワークスペースでブラウザーやデスクトップアプリを実行
- 03ライブラリー不足や TLS 信頼の問題を調査
- 04Tailscale 経由で別のワークスペースに接続
MCP 対応
Replit 上で llm_wiki デスクトップアプリを動かし、HTTP 経由で MCP を公開する手順を説明しています。トークンとサーバーポートの設定も扱います。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skynight137/ skills --skill replitインストール後、Claude Code で/replitを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/anti-bot-detection.mdbrowser-compare-report.mdbrowser-lanes.mdcamofox.mdexposing-ports.mdhermes-on-replit.mdjoin-bridge.mdlarge-files-disk.mdllm-wiki-server.mdnix.mdplatform.mdplaywright-chromium.mdpublic-ports.mdsession-keepalive.mdsetup-tool-checklist.mdshell-rc-and-zsh.mdssh-hop-manual.mdtls-trust.mdtoolchain-layout.mdweb-content-lanes.mdscripts/camofox.pydot_replit.pyjoin-lifecycle-test.shprobe-tailnet-peers.shsetup.shscripts/llm-wiki/install.shllmwiki-server.jsrun-public.shrun-ui.shstart-app.shREADME(原文)English
プロジェクトの README よりこの README の日本語訳はまだありません。以下は英語の原文です。日本語の紹介はページ上部をご覧ください。
Replit toolkit — one skill, four lanes
Lane 5 pointer: reaching a second box from this one (sshd bootstrap, proxy
port reality, command -v/banner-probe traps, path-not-paste delivery) lives in
references/join-bridge.md. Transport rule before you start: ssh to another box ONLY
through a Host block whose ProxyCommand is the tailscale daemon's own nc
(tailscale --socket=<sock> nc %h %p); a raw dial to the tailnet IP, an -J jump, or a
hand-rolled SOCKS5 forward each lands on the platform gateway (binary dEob… preamble,
then close) and reads as "banner exchange timeout" on a perfectly healthy box. Copy the
working block from an existing Host entry instead of inventing a transport.
Reachability is per node, not per tailnet: a
listed peer answers only once it has joined, run the prep there and carries the hop
key. Survey with scripts/probe-tailnet-peers.sh [port] and never promise "any
machine on the tailnet"; scan one host across many ports with
scripts/socks5-probe.py <ip> <port>... (reachability reading in
references/join-bridge.md). The box's own row is printed as self there — resolve self
from the JSON Self field, never from a name you remember, since the plain status
table labels no row.
Everything here is specific to running on a Replit/Nix workspace. The platform breaks normal Linux assumptions ($HOME wiped, /nix/store GC, npm firewall), and each lane below is a verified playbook for one job.
Read references/platform.md first if you are new to the box — every
other lane assumes its persistence rules.
| You want to… | Read | Scripts |
|---|---|---|
| persist data/env/rc/git, understand what survives a restart, fix env-var plumbing | references/platform.md | setup.sh, dot_replit.py |
| add a tool to setup.sh, decide where its payload/config/cache lives, name a flag or var | references/toolchain-layout.md | scripts/setup/ |
get a system lib that isn't installed (GTK, libatomic, any .so), debug cannot open shared object file | references/nix.md | scripts/setup/generate-closure.sh, libpool.sh |
| drive Playwright without downloading a browser (the box ships Chromium) | references/playwright-chromium.md | $REPLIT_PLAYWRIGHT_CHROMIUM_EXECUTABLE, start-replit-chromium.sh |
open bot-hardened sites (Cloudflare/Turnstile/WAF), keep logged-in Firefox sessions alive, agent browsing on Hermes (camofox MCP — browser_exec is broken here) | references/camofox.md | setup.sh --camofox (npm lane), camofox-browser, camofox.py |
| pick the right browser lane (4 exist: Replit Playwright Chromium, Hermes Chrome 145, agent-browser CLI, Camofox) | references/browser-lanes.md | $REPLIT_PLAYWRIGHT_CHROMIUM_EXECUTABLE, .hermes/tools/chromium-1208, agent-browser, :9377 |
| run a desktop GUI app (AppImage/Tauri/GTK) headless — Xvfb, window manager, software GL, EGL/loader traps | headless-gui-linux skill | start.sh-style launcher, xwininfo -root -tree gates |
| tell whether a browser is being detected as a bot; test sites + how to read them | references/anti-bot-detection.md | (test pages: sannysoft, CreepJS, BrowserLeaks, NopeCHA) |
| compare every browser lane on this box (versions, loads, webdriver/stealth, WebGL) — full run report | references/browser-compare-report.md | start-replit-chromium.sh, resolve-libs.sh, launch-hermes-chrome |
| make an HTTPS client (Python, Node, git, uv) trust the Replit proxy without disabling verification | references/tls-trust.md | setup/rc.sh, setup/common.sh (TLS block) |
expose a service to a browser (declared ports, port-in-URL, HTTP-only router, Tailscale userspace, [[workflows]] wiring, hardcoded binds) | references/exposing-ports.md | .replit [[ports]] / [[workflows]] |
decide which ports may be public on Replit, and how to expose one (declared in .replit, edge opens only after a restart; supported list = 80, 3000-3003, 4200, 5000, 5173, 6000, 6800, 8000, 8008, 8080, 8081, 8099, 9000) | references/public-ports.md | — |
| serve a knowledge base to agents (llm_wiki desktop app headless + MCP over HTTP, read-only token, supervisor) plus a noVNC view of the real desktop UI | references/llm-wiki-server.md | setup.sh --llm-wiki (bundle: scripts/llm-wiki/), run-public.sh, run-ui.sh, start-app.sh, llmwiki-server.js. Public port = LLM_WIKI_PORT (default 8080; 8081 only where the Hermes dashboard owns 8080); loopback 8199 (no token) is what local agents use. MCP lives ONLY on the server ports — the app API 19828 answers {"error":"Not found"} for /mcp. Token header x-llm-wiki-token or Authorization: Bearer; write tool is llm_wiki_write_page with field content (not body). This box's wiki-of-record lives on the -wiki box (role suffix): bundle $REPL_HOME/llmwiki-app, .replit workflows llm-wiki-server (run-public.sh, waitForPort 8199), llm-wiki-ui (run-ui.sh) and tailscale-join, and the Hermes MCP entry https://<that box's REPLIT_DEV_DOMAIN>/mcp + Authorization: Bearer ${LLM_WIKI_API_TOKEN} read from the profile .env (headers DO expand ${VAR}; mcp_servers.*.env values do not). The project dir must EXIST on disk — a stale LLM_WIKI_PROJECT naming a missing directory makes every MCP call fail with Failed to resolve project path; create the dir with its wiki/ skeleton or point llm_wiki_set_project at an existing one. A local wiki/ folder is a mirror only. Pages grounded in box experience carry the provenance tag repl (a reader outside Replit can skip them on that tag alone); the project's wiki/SCHEMA.md records the tag taxonomy, the required frontmatter, and the evidence rule (`target |
| own the interactive shell (zsh + oh-my-zsh + powerlevel10k, durable dotfiles, rc wiring rules) | references/shell-rc-and-zsh.md | scripts/setup/zsh.sh, scripts/setup/rc.sh, scripts/setup/common.sh (tool-var registry) |
| host a browser-only web terminal / get a real shell on a second box, or reach a fresh disposable box from here | references/join-bridge.md | scripts/setup.sh --tailscale --join (host key + key-only sshd config + tailnet join, persistent ~/.ssh, prints one workspace-relative workflow task line) — --tailscale alone installs the binaries only, --workflow = the same in workflow mode; set TAILSCALE_NODE_NAME=<name> inline on the workflow args (or a Replit secret) to pin the tailnet node name, otherwise the console name is left alone |
add a tool or flag to setup.sh (the ~2 dozen call sites, repair/verify/clean, the rc-writer harness) | references/setup-tool-checklist.md | setup.sh, scripts/setup/*.sh |
Hermes-on-Replit recovery specifically (uv --locked trailing-slash failures,
official Node ≥22 tarballs needing libatomic): references/hermes-on-replit.md.
The platform in seven facts (detail in platform.md)
$HOME(/home/runner) is wiped on recreate;$REPL_HOME(/home/runner/workspace) is persistent. Anything you want to survive lives under the workspace. The XDG vars are pre-set into the workspace, but do not trust that implicitly — check withprintenv | grep XDG_.~/.sshand~/.local/binare NOT exceptions even though they usually exist and look aged: the boot script rewritesauthorized_keysand re-publishes the launchers, so an intact file proves nothing — compare the directory mtime with uptime before trusting one. A tailnet node key under$HOMEtherefore re-registers on every boot (new100.x, new node name) and every saved URL /.ssh/configentry rots; keep node state in$TAILSCALE_STATE_DIR($REPL_HOME/.local/state/tailscale, the XDG state home), whichsetup.sh --tailscale/--joinalready use (legacy.local/share/tailscale/stateis adopted automatically). A tool that insists on a$HOMEpath (~/.ssh,~/.gnupg) is symlinked into$REPL_HOME(move the existing dir's contents in first, thenln -s) — seereferences/platform.md§1.- Daemons never autostart from a shell rc.
.bashrc/.profile/$REPL_HOME/.config/bashrcre-source on every terminal the operator opens, so a service line there re-fires per terminal and needs a fresh guard in each shell. Long-running services (sshd,tailscaled, the llm-wiki stack) go in a.replit[workflows]entry, which runs once per boot — added in the Replit UI by the operator from one printed command, not written into.replitby the installer: names and ordering stay theirs. rc carries PATH/env only. Seereferences/shell-rc-and-zsh.md. The installer's whole notice is that one command line, with the path made relative to the workspace (workflows run with the workspace as cwd, so a moved/renamed skill dir cannot leave a dead absolute path). When an entry already exists, verify the path it names BEFORE checking for its extra flags — otherwise a dead path hides behind the "already wired" branch — and grep only the line that carries the app's own flag, since other workflows point at the same script. Corollary for code: a workflow'sargsmust point at a persistent and workspace-relative path (the skill dir under$REPL_HOME), never at a script copied into$HOME, and it must be idempotent — re-running is the normal repair path, so skip a live daemon instead of killing it (killingtailscaledcuts the very SOCKS hop the currentsshruns over). - Everything system-level comes from
/nix/store, and store paths get GC'd. A path that worked yesterday may not exist today; long-lived things need a durable copy outside the store (see nix.md, hermes-on-replit.md). Two stores exist: the shared read-only/nix/store(~39 GB) and a per-repl$REPL_HOME/.store/nix(~0.5 GB) that holdsnix build/nix profileoutput for THIS repl so it survives a sandbox recreate. Only tools that need system libraries ever touch either one — AppImage/desktop apps (llm-wiki's Tauri bundle needs libX11/mesa plus Xvfb/fluxbox/shared-mime-info, camofox's Firefox needs the GTK closure), while static binaries (rclone, aria2c, qbittorrent-nox, ffmpeg, cloudflared, tailscale, sshx) and self-contained archives (node, uv) never do. So "why does only this tool use nix?" is answered byldd <binary>: a static binary lists nothing, a GUI one lists libs that must be resolved into a closure (.ldpath/closure.txt). $REPLIT_LD_LIBRARY_PATHis never enough for GUI browsers — you need the full transitive closure (nix.md §3;scripts/setup/generate-closure.sh).- npm is behind a package firewall; pin
--registry=https://registry.npmjs.org/. It can reject a transitive dependency outright (403 ... Blocked by Security Policyon a CVE-flagged package), and only the dev tree pulls the usual offender. This shell also exportsNODE_ENV=production, and npm then silently omits devDependencies —tsc: command not foundis the tell. Prefix installs and builds withenv -u NODE_ENV. - Replit ships a Playwright-managed Chromium —
$REPLIT_PLAYWRIGHT_CHROMIUM_EXECUTABLE; neverplaywright install. - Disk splits 4 GB / 256 GB — big files only on the workspace volume.
$HOME,/tmpand/mnt/scratchare each a ~4 GB overlay/tmpfs (/tmpand/mnt/scratchare RAM);$REPL_HOMEis the 256 GB volume and$XDG_DATA_HOME(/home/runner/workspace/.local/share) sits on it, while the~/.local/sharespelling does not. Download anything over a couple of GB to$XDG_DATA_HOME/<slug>/, never to/tmp,/mnt/scratchor~/. Seereferences/large-files-disk.md.
Prefer rg over grep when searching the box or this repo (recursive,
gitignore-aware, far faster); it is on PATH and also staged under
$HERMES_HOME/tools/ripgrep-*. Use grep only in scripts that must run
without ripgrep.
Scripts (entry point in scripts/, modules in scripts/setup/)
setup.sh— thin entry point: install-root resolution + version config, thensources the modules inscripts/setup/(common→ui_menu/bin/fix/rc/doctor/installers/cleanup→cli). All wiring helpers resolve via$SCRIPT_DIR, so every module andsetup/must sit besidesetup.sh. Install root: resolved ONCE bysetup/root.shbefore anything derives a path —SETUP_ROOTenv (explicit operator choice, recorded for later runs) → recorded root → platform workspace ($REPL_HOME) → die. There is no silent fallback layout any more; every run printssetup root: <path> (source: …), and a root outside the persistent workspace warns (it is wiped on recreate) but proceeds when chosen explicitly. Installs record their payload paths in a manifest (<root>/.config/replit-setup/manifest.tsv) viarun_install_step;clean()sweeps those exact paths after the per-tool clean (Hermes-backup-skipped labels exempt; tailscale/join are not recorded — live-daemon state owned byclean_tailscale). Layout rules:references/toolchain-layout.md. It is idempotent toolchain/env provisioning (node/uv/python pins, LD paths, rc chain); run it after any recreate.--doctorverifies installs + wiring read-only (exit 2 = wiring gaps);--fixrewrites ALL wiring with zero downloads/reinstalls — safe after a $HOME wipe or a partial recreate.--doctor --fix= report then repair. Re-running--fixis a byte-identical fixed point.- Install isolation: each tool installs in its own step
(
run_install_step); one failing tool warns and the run CONTINUES to the rest, then lists the failures and exits non-zero.--allno longer aborts at the first broken installer. - Clean safety:
--cleanprints a pre-flight list of exactly what will be removed; tools that own user data are protected —hermesis backed up with its own CLI (hermes backup, restorable viahermes import), the archive copied to$XDG_CONFIG_HOME/hermes/hermes-backup.zip(OUTSIDE the removed dir) first and left intact if the backup fails (manual-zip fallback when the CLI is unavailable); ollama models + camofox server state are preserved. A bare non-interactive--cleanis REFUSED (use--clean all -y). - A
clean_*must undo everything itsinstall_*created, not just the directory. The leftovers are the ones that rot: entries the installer appended to shared files (~/.ssh/authorized_keys,~/.ssh/config,known_hosts) and socket files. Remove those too, but only the shape you wrote — match your own port/socket/tag comment, so a foreign block survives. One deliberate exception: the tailnet node key ($TAILSCALE_STATE_DIR/tailscaled.state) is KEPT, so a rejoin reuses the same node (offline → online, no new console row) instead of registering a fresh identity and leaving the old one stranded. Purging it is opt-in and is a first-class flag —setup.sh --clean tailscale --purge-identity(-pi), the env varTAILSCALE_PURGE_IDENTITY=truestill honoured behind it — and it is a one-shot knob read at clean time, never a per-machine setting (do not write it into.replitor any env file; apurge-markergrep hit on a box only proves the installed script is current), and only then is there a stale console entry for the operator to delete by hand. Duplicates in the tailnet are the symptom of a wiped state dir, not of rejoining. - When the clean kills the transport it runs over, the clean must be detached and
verified from a file. Killing
sshdsurvives (children are orphaned); killing the tailnet/tunnel client takes the whole leg down and the client times out mid-sweep. Run it undersetsid ... >& clean.log &, wait for the last line of output, and write the post-clean assertions to a file on the box — the session that started it is gone. The same applies to ANY long remote job (a full--clean all, a bulk install→clean sweep): a foregroundssh 'bash setup.sh …'is killed when the local client's timeout fires and takes the remote run with it, so detach (setsid/nohup, stdin/dev/null) and poll a log file. Detaching also costs you the command's own stdout: a remote job left in the background keeps the ssh channel's stdout open, sossh box '... app.sh > /tmp/log 2>&1 & sleep 18; echo RESULT'returns NOTHING at all — not even the echo — while every step ran correctly. Write the results (and each variant's metrics) to files on the box and read them back in a SECOND call; a truncated or empty batch result is not evidence that the commands failed. - Conflict-free installs. A box normally ALREADY has the tool: hand-installed
into some arbitrary directory, symlinked onto PATH, often with a
.replitworkflow running that copy. The managed install must win without breaking any of it — ADOPT the existing user data/state rather than re-initialising it (so nothing needs re-auth and no config is lost), make every script and workflow that runs the tool prefer the managed binary, report a foreign symlink before replacing it, and never leave the old copy silently in charge whilesetup.shreports success. Audit all four sites:$XDG_BIN_HOME/<bin>,$XDG_DATA_HOME/<tool>/, the.replitworkflowargs, and any operator script that hardcodes a path. Pitfall encoded in the script:--fixnever execs thehermeslauncher — any subcommand can boot the full source-update cycle (venv sync, npm build, GB-scale runtime clone). config.yaml is edited withyq -iwhen available (awk fallback), then verified.--camofoxinstalls the npm-global Camofox browser (@askjo/camofox-browser+camofox-browser-mcpon PATH, engine in$XDG_CACHE_HOME/camoufox), applies the Replit lib closure + WebGL auto-skip, and writes a runtime env snippet plus a launcher shim ($XDG_BIN_HOME/launch-camofox-browser) for shells that cannot source it — workflows, the Run button, MCP/cron children. A barecamofox-browserin those shells dies onlibgtk-3.so.0: cannot open shared object fileand surfaces as an opaque 500 on cookie import; use the snippet or the shim. Seereferences/camofox.md§"npm-global lane".--camofoxREQUIRES the managed Node.js 26 (the--nodetarball install) and installs it first when missing — never the platform nix node: the closure is generated from the node running the install, and a platform-node closure prepends store libs whose sqlite needs a newer glibc than the system one, so Firefox dies at every launch while the server looks healthy. The closure is stamped with its build node (closure.node); a stamp mismatch (or a pre-stamp closure) regenerates it automatically on the next--camofoxrun. Cold store (fresh workspace, noreplit.nix):--camofoxhandles it by command — no hand-editing. It writes/merges the workspace-rootreplit.nixwith the five Camofox deps plus a[nix] channelin.replit(persistence lane; without the channel the platform drops the repl into recovery mode and never rebuilds; the platform does not rebuild on the write either way, so it applies at the next rebuild/restart) AND warms this container immediately withnix-env -iA(immediate lane; Xvfb comes via the legacynixpkgs.xorg.xorgserverattr —nixpkgs.xorg.xvfbis not a validnix-env -iApath). The generated launcher's fix-it command is derived from$SCRIPT_DIR— never a hardcoded clone path.
- Install isolation: each tool installs in its own step
(
dot_replit.py— structural.replit [userenv]edits (tomlkit, no regex).- Verify the pin after every
--fix:write_replit_bashrcneeds tomlkit (from.venv,python3, oruv). On a fresh box none of them has tomlkit, so the function printspython tomlkit not found, write REPLIT_BASHRC manuallyand still returns 0. That is a failure: workflow shells then lack the line and break silently. Check withgrep REPLIT_BASHRC $REPL_HOME/.replit. Install tomlkit into.venvbefore runningsetup.sh --fixon a fresh machine, or add the line by hand under[userenv.shared].
- Verify the pin after every
camofox.py— the scraping CLI (open/nav/eval/screenshot, cookie import, keep-alive loop). See camofox.md for its env-file contract.scripts/setup/generate-closure.sh/libpool.sh— transitive lib closure (per-process) + pool healing (additive-only).$XDG_BIN_HOME/launch-camofox-browser— generated at install (not a repo file): sources the closureenv.sh, preflights the install, then startscamofox-browserwith its full log going to$REPL_HOME/logs/camofox-browser.log, prints a short summary, and waits on the server. The entry point for any shell that cannot inline the snippet —.replitworkflows, the Run button, cron/MCP children.tests/camofox_mcp_check.sh— one-shot MCP health probe (waits for REST, runs a real stdio handshake, asserts the 11camofox_*tools; exit 0 = live). Used by the.replit"camofox mcp" workflow — the adapter itself is stdio-only, running it standalone just idles forever.
Maintaining this skill (verify, don't trust the edit tool)
- Changes to this skill's repo go on a feature branch in a
.worktrees/<name>worktree and open a PR for review. Never commit directly tomain. - PRs here are squash-merged, so a branch's commits never become ancestors of
main. Two consequences: after a merge,git rebase origin/mainbefore opening a follow-up PR, or its diff re-lists the work that already landed (GitHub shows the old commits plus a duplicate change); and if the PR merges while you are still editing, the later commits are stranded on a merged branch — branch off the tip, rebase ontoorigin/main, push that new branch. Confirm withgit diff --stat origin/main..HEADthat the PR carries only the intended files before you describe it. - Test against the live target before committing, and commit only after a passing run. Delete throwaway test scripts and key files first, so the PR holds only the fix.
- Staging machine is repl-test. The skill's live stack (wiki of record) runs there; validate
setup.shinstalls on repl-test in a fake root before claiming ready. When repl-test is offline, ASK the operator (wake it vs. fake root) instead of picking silently. The operator names boxes by ROLE SUFFIX —<thing>-test(staging / sweep target),<thing>-wiki(knowledge-base box),<thing>-cfx(camofox box) — so read the suffix before choosing a target, and never assume a box is the one you remember: resolve it fromtailscale status --json(Selffor this box, the peer rows for the rest) instead of a name from chat. - A bulk install→clean sweep on a fresh box fills the 4 GB overlay mid-list
(zsh's
nix build, hermes' ~2.3 GB$HOME/.hermes); watchdf -hbetween tools and remove failed-install leftovers before continuing —references/setup-tool-checklist.md. - The IDE terminal's rc wrapper can be clobbered by a toolchain write (the
store overlay is writable): tell = correct
$REPLIT_BASHRCbut dead aliases; restore from the pristine*-replit-bashrc*twin (the larger store file) —references/shell-rc-and-zsh.md. - A fake-root
--joinstill joins the REAL tailnet. The tailscale state dir is the XDG state home ($REPL_HOME/.local/state/tailscale), so a fake-root test re-registers the box's own node identity and can flap the live node; to test join isolation point the statedir at a scratch path first. And--join -w(workflow mode) keeps its task in the FOREGROUND by design — it prints a summary, then watches the tailscaled socket for as long as the daemon lives; a long-hanging test is the feature working, not a hang. Workflow consoles never stream runtime logs (that hangs the Replit UI): full logs live in files under$REPL_HOME/logs/(tailscaled's included), per the rule inreferences/exposing-ports.md. - After a skill's PR merges, the feature branch is gone.
npx -y skills add owner/repo#branchthen fails withCould not find remote branch; re-point installs at the default branch (skills add owner/repo) once merged. - Never declare a workflow or MCP entry missing from a truncated grep.
.replitkeeps each workflow in its own[[workflows.workflow]]block, so agrep -A4 <name>window can display a different block and read as absence — list every header first (grep -n '^name = ' .replit) and quote the block you actually read before telling the operator something is not there. Same discipline for MCP parity: after any change, compare themcp_serverskey lists of the default config and EACH profile (hermes mcp list/hermes -p <p> mcp list), because a barehermes mcp addwrites the default config only whilehermes chatand the WebUI resolve the profile one. - The workflow set is an invariant across the operator's boxes, not per-box improvisation.
Every box carries
tailscale-join(bash .agents/skills/replit/scripts/setup.sh --tailscale -j -w) withwaitForPort = 1055(the tailscaled SOCKS5 port) so the boxes stay one tailnet, plus its own role workflow (launch-camofox-browseron80,llm-wiki-serveron8199) andskills add replit(npx -y skills add skynight137/skills -s replit).Projectchains them by NAME, so a workflow name is an identifier: keep it kebab-case without spaces and move everyargs = "<old name>"reference in the same edit — aProjectstep pointing at a renamed or absent block silently drops that boot stage. AwaitForPortthat does not equal the port the CLIENTS use makes a healthy service read as dead (the health check waits on a port nothing opens). Editing procedure, in order: pull the file off the box, render it withtomlliband list the workflow names (a grep window shows the neighbouring[[workflows.workflow]]block and makes a present workflow look absent), back up on the box (.replit.bak-$(date +%s)), write the edited text back, then re-fetch and re-parse to verify names,waitForPort,[nix]and[[ports]]. Never hand-edit.replitTOML blind, and nevergrep -A4it to answer an existence question. - Never rewrite a script file while a run of it is still executing. Bash reads a script incrementally by byte offset, so overwriting the file mid-run makes the live process resume inside different text — the symptom is a syntax error on a line that is perfectly valid on disk (
syntax error near unexpected token '('at alog "...(-lw)"line). Patch a copy, or let the run finish, then edit. - A runner script must pin its own roots;
/run/replit/env/latestis not trustworthy for them. The file is regenerated and can lag, so a shell that only sources it resolvesREPL_HOME/XDG_*to$HOME— the service still works, but its state (vnc password, logs, caches) lands in$HOME/.config/...and dies on the next recreate. Both llm-wiki runners therefore fall back to the bundle's parent and export the XDG roots after sourcing it:REPL_HOME="${REPL_HOME:-$(cd "$APP/.." && pwd)}"plusexport XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$REPL_HOME/.config}". Tell-tale: state under$HOME/.config/<service>/while the box expects it under$REPL_HOME/.config/<service>/; prove which one a running daemon actually uses from its own argv (x11vnccarries-rfbauth <path>) before believing the config. - Liveness of a display comes from the Xvfb PROCESS, not from
/tmp/.X11-unix/X<n>. tmpfs sweeps the socket while the server keeps running, so a socket-only check concludes "display dead", starts a second Xvfb and gets_XSERVTransSocketUNIXCreateListener ... server already running— the supervisor then logsXvfb FAILEDevery loop and the UI never comes up. Test the process for that display (pgrep -f "Xvfb.*[[:space:]]${DISP}([[:space:]]|$)"); when the socket is missing but the process lives, log that instead of restarting it. Pick the artifact that is authoritative for THIS service — for the tailnet it is the socket answering, for an X display it is the server process. - Keep every commit to your own files. When the tree carries the user's uncommitted edits (a bullet, a whole new reference file), stage by PATH and never
git add -A; when their hunk sits in a file you also have to edit — SKILL.md is the usual meeting point — save the file aside, patch their hunk out, commit your change, then restore theirs so it stays uncommitted. Commit their work only when they ask, as its own commit whose message describes their change. - Close the loop on a merged PR: delete its branch locally and on the remote, and remove any worktree it used. The user treats leftover branches and worktrees as clutter to be cleared, not as history to keep.
- Adding or changing a tool flag touches roughly two dozen places in eight files. Work
references/setup-tool-checklist.mdin order and run its test sequence: a half-wired flag does not error, it is invisible, or the tool installs and then cannot be repaired or cleaned. - Never write a silent fallback (
except ImportError: pass, a default that hides a failed dependency or trust store). Fail with the name of what is missing; a silent pass hid the real TLS cause here. - Retiring a tool var is not "stop registering it". Dropping the name from
wire_tool/record_tool_env_varsleaves the oldexport NAME=line in place:emit_managed_blockre-adopts every export it finds inside its own markers and re-emits it, so an inherited value keeps the line alive forever. Add the name to_FIXED_RC_ENV_VARS(the suppress list) with a comment saying why — that drops the stale line on the next write and stops it coming back. - Per-machine identity does not belong in the rc at all. A var that describes this box (a tailnet node name, a chosen port) is the operator's decision, pinned at the call site — the workflow
argsor a Replit secret — so the script reads it from the env and never writes it into every shell. Only paths identical on every box get exported. - Mask a secret wherever the script echoes it. Tools print their own argv (
tailscale upincludes the auth key verbatim), so pipe such output through a filter before the terminal sees it:| sed -E 's/(tskey-)[A-Za-z0-9_-]+/\1***/g'. - Only path vars belong in the rc registry; a human choice stays out of it. A var one operator sets differently per box (node name, persona) is pinned where that machine is described — the workflow
argsor a Replit secret — and read from the environment at use. The managed block re-emits whatever it finds, so once the var has lived in the block it is re-adopted forever; retiring it fromwire_tool/record_tool_env_varsis not enough, add it to the retired list inrc.shor an inherited value resurrects the export on every write. - One variable per thing: drop a legacy alias, keep the CLI flag alias. The old flag spelling is worth keeping as a dispatch alias, but a parallel
OLD_VAR:-NEW_VARfallback in the env is not — it hides which one is live and doubles every place the operator must set. - Never snapshot a canonical var into a private
_PREFIX_KEYcopy just to survive anunset. The operator reads_CAMOFOX_KEY="${CAMOFOX_ACCESS_KEY:-}"… re-export as a home-made duplicate of a var they already have and asks why it is not simply the canonical name. Fix what drops it instead: narrow the guard (the camofox snippet now unsets the access key ONLY on the default loopback port) or set the input the guard reads (export PORTbefore sourcing the snippet), so the canonical name is the one that lives. - Mask secrets in anything streamed or logged.
tailscale upechoes the full auth key in its own output; pipe throughsed -E 's/(tskey-)[A-Za-z0-9_-]+/\1***/g'before it reaches the terminal or a log. - Inside a quoted heredoc (
<<'PY') do not escape quotes. Bash passes the text verbatim, so\"reaches python as backslash-quote; the same\"copied into a shell-levelgrep 'name = \"x\"'then matches nothing, because a single-quoted pattern keeps the backslash. Write plain", andbash -nthe file after any block-level rewrite. - Deleting a directory the box used to run starts with a config sweep, not
rm. Grep the config surface for absolute paths into it —.replitworkflowargsandwaitForPort,$REPLIT_BASHRC/rc files,~/.ssh/config, operator scripts — because a workflow pointing at a removed script still reads as wired and fails only at the next boot, silently (the job is reaped at once). Check whether the path was already dead before the delete: a directory can be gone while two workflows still name it, which is how stale wiring hides. Move the identity/config that must survive (a pinned node name, a port) into the surviving workflow'sargs, never into an rc. - Renaming a flag or token is the same sweep, run the other way. Every site that spells the old token moves in one commit: usage/help, arg parser, cleanup dispatch, menu label plus its mapping, workflow
args(migrate in place,sed -Eon the token only), docs and the skill's own tool row. Two per-site constraints: rename a workflownameonly when nothing else already holds the new name (Replit drops one of two identical names), and leave a state DIR at its old name when moving it would mint a new host key — every client'sknown_hoststhen warns. Keep the old flag spelling as an accepted alias so existing.replitentries and the operator's habits still work, then verify withgrep -rn '<old>' scripts/ docs/ skills/: whatever survives must be an intentional alias or a path you chose to keep. - Derive a path once; consumers read it from the environment. A var computed from another (
XA_STATE_DIRoffXA_DATA_DIR, the socket inside the state dir) is assigned incommon.shalone, and every consumer takes$XA_SOCKETfrom the env instead of re-spelling$XA_DATA_DIR/state/...— one re-spelled site is enough to drift the next time the layout moves. Register such vars in the installer and in the repair path (--fix): a var wired only in the installer evaporates the first time someone repairs instead of installs, which is also why--doctorshould warn when the export is missing from the rc block. - A worktree branch cannot see edits sitting uncommitted in the main checkout. The worktree shares
.gitbut has its own files, so doc or config edits made in the main checkout belong to no branch: the PR from the worktree silently misses them and the latergit pullinto main aborts on the dirty file.git statusthe MAIN checkout before merging; when something is stranded, re-apply it on top of the freshly pulledorigin/mainas a follow-up commit — not a stash pop, because the rename probably moved the file it was written against. - A heredoc'd helper inside a shell script must not carry backslash escapes. Inside
<<'PY'a backslash is literal, so\"reaches Python as an escaped quote (survivable) and reaches a shell-levelgrep -qF 'name = \"x\"'as a literal backslash, which then never matches. Keep quotes raw inside the heredoc and re-read the block withsed -n '<a>,<b>p'after patching —bash -npasses happily on a pattern that matches nothing.
When one batch touches many files or repeats an old_string, VERIFY the effect
on disk before moving on:
rgthe new text (a "failed"/"no match" line can be a benign duplicate-key retry) and confirm it appears the expected number of times — twice means the edit applied twice.- Never compose a
sed -Escript by hand once it contains quotes or\1: run the exact expression on a scratch copy first. Two failures in a row (|rejected as the delimiter;(--join)?written where the text has a space, so the pattern silently never matched whilerc=0claimed success) look identical to a logic bug, and the script "succeeds" without editing anything. sed -ed -i f1 f2mengosongkan KEDUA file.-edmenempelkandke script, jadi setiap baris dihapus (wc -l= 0) danrc=0tetap terlihat sukses. Tulissed -e 's#a#b#' -e 's#c#d#' -i f1 f2, danwc -ltiap file tepat setelah edit multi--e. Pemulihan:git checkout -- <file>— patch yang belum di-commit di file itu ikut hilang, jadi siapkan untuk meng-apply-ulang.- Pass absolute paths: a
cdfrom a previous terminal call persists, so a relative path can resolve against the wrong directory. - Trust base after any edit:
bash -non every module (the setup.sh syntax gate runs the same check),shellcheck -S warningon changed files, and re-run the module harness.
Camofox public access
- With
CAMOFOX_ACCESS_KEYset, every route except/healthrequiresAuthorization: Bearer <access key>. A browser address bar cannot send that header, so{"error":"Unauthorized"}in a browser means the gate works. It is not a fault. - The global gate checks the access key only;
CAMOFOX_API_KEYgets 401 on/tabs. Cookie import is the reverse:POST /sessions/<u>/cookieschecks the API key only, so the access key gets 403 there.camofox.pysends the access key everywhere except cookie import, which uses the API key. - Removing the access key opens every route to anyone with the URL. Never do that to clear a 401 on a public server.
- The runtime env snippet keeps the access key only on the DEFAULT loopback port. It unsets
CAMOFOX_ACCESS_KEYwhenCAMOFOX_BIND_HOSTis loopback AND the port is unset/:9377; a.replitworkflow that serves the box's:80sets neither the bind host nor a port, so ORDER matters — exportPORTbefore sourcing the snippet, or the public port comes up keyless. Make an unkeyedcurl(expect 401) part of every public deployment, and check the key's presence in the running process (tr '\0' '\n' < /proc/<pid>/environ | grep CAMOFOX_) rather than assuming the workflow env reached it. - The MCP adapter runs as a LOCAL process pointed at the box's public HTTPS URL — not as an
ssh <box> bash <wrapper>hop. The operator treats an ssh-spawned MCP server as the wrong shape when the service is already published (kok mcp kita tetep pake ssh? udah pindah ke https langsung): ssh is for managing the box, HTTPS is for using the service. Default recipe:npm i -g @askjo/camofox-browser-mcpinto the workspace npm prefix, then register a local wrapper withhermes mcp add <name> --command <wrapper> --env CAMOFOX_BASE_URL=https://<box-domain> CAMOFOX_USER_ID=<id> ...— no key in--env: Hermes does not expand${VAR}insidemcp_servers.*.env, so a${CAMOFOX_ACCESS_KEY}there reaches the adapter as a literal string and every call 401s while a directcurlwith the same token works. The wrapper readsCAMOFOX_ACCESS_KEY/CAMOFOX_API_KEYout of the profile.envitself (one home, nothing duplicated intoconfig.yaml). Register withhermes -p <profile> mcp add ...:hermes chatand the WebUI resolve the PROFILE config (profiles/<p>/config.yaml) while a barehermes mcp addwrites the DEFAULT one, so the CLI keeps its old entry and 401s after a "successful" add. The wrapper only fixes the runtime the adapter inherits:LD_LIBRARY_PATH=$REPL_HOME/.local/lib(libatomic for the workspace node),PATHwith the node bin dir, and an ABSOLUTEnode .../server.mjsexec —#!/usr/bin/env nodeinside a server that Hermes spawns finds no node and the connect fails asConnection closed. Keep the ssh wrapper only as the fallback for a service that is not published over HTTPS; in that shape it needs three things or every call fails whilehermes mcp teststays green (that command lists only STATIC tool defs — always issue one real tool call):source /run/replit/env/latest(ambient key; without it401 Unauthorized),PORTexported so the snippet keeps the key, andCAMOFOX_BASE_URLpointed at the port the server actually binds (adapter defaults to:9377→camofox error: fetch failed).camofox_import_cookiesparses the Netscape file on the MACHINE RUNNING THE ADAPTER and uploads it, so the file must exist there; a leading-dot domain raises FirefoxNS_ERROR_ILLEGAL_VALUE, and a warm user context can reject a synthetic cookie that a freshCAMOFOX_USER_IDaccepted — use real browser-exported cookie files, the same onescamofox.py --cookiestakes. Two operational details:hermes mcp addends with an interactiveEnable all N tools? [Y/n/select]prompt that silently CANCELS when stdin is not a TTY — pipeprintf 'Y\n' |to enable the tools non-interactively, then confirm withhermes mcp list; and the wrapper is a Hermes-home helper (parameterisedREPL_HOME/HERMES_PROFILE, keys read from the profile.env) — it lives with Hermes, NOT in this skill (operator decision 2026-11-05); write it once in the Hermes home rather than retyping the LD_LIBRARY_PATH/node/env preamble." - A public test goes through the public URL with a header-capable client (
curlorcamofox.py). A localhost pass proves the server works, not the proxy path. - Keep TLS verification on in every client. Diagnose a public failure in this order:
curl -sS https://<domain>:<port>/health(verified) first, thencurl -sk(skip verify). If the verified call fails with curl error 60 and the-kcall returns 200, the server is fine and this client's trust store lacks the Replit proxy root CA (see the bundle rule below); a502from the domain means nothing listens on that port. Run the verified call before naming any cause. A client with verification off proves reachability only; never report it as a pass for auth or the tool path. - curl and Python can trust different stores on the same box. curl reads
/etc/ssl/certs/ca-certificates.crt, which holds the Replit proxy root; Python's default context hascafile=Noneand onlycapath=/etc/ssl/certs, so the identical URL fails withCERTIFICATE_VERIFY_FAILED. Fix it in the client:ssl.create_default_context(cafile='/etc/ssl/certs/ca-certificates.crt')and passcontext=to everyurlopen. Confirm the root is in that bundle with a verifiedcurl(no-k) first. certifi's bundle lacks the proxy root, socertifi.where()does not fix it. Do not disable verification;PYTHONHTTPSVERIFYis not a Python setting and does nothing. The per-client variable table (Node, Pythonurllib/httpx/requests, git, uv) and the generator fix are inreferences/tls-trust.md. - Node has the same split, and
NODE_OPTIONS=--use-system-caalone is not enough.NODE_EXTRA_CA_CERTSoverrides the system store, and the Nix bundle it often points at lacks the proxy root. SetNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crtfor the MCP adapter and anyfetchclient, and keep verification on. Confirm withfetch(url)in the same environment before running the adapter. camofox.pyprints "not ready" for every probe failure, including certificate errors. To see the real exception, runcamofox.req(None, 'GET', '/health')withCAMOFOX_BASE_URLset; the_errorfield names the cause.- The Camofox MCP adapter reports every network failure as
camofox error: fetch failed, which hides the TLS cause. Iftools/listworks but tool calls fail with that text, the adapter'sfetchcannot verify the proxy, not the server. Fix the client trust store (above), then rerun the tool call. - Read a Replit secret into a shell variable with
replit secrets exec --keys NAME -- sh -c 'printf %s "$NAME"'. The--form is required; without it the output is empty and looks like a missing key.--keystakes a COMMA-SEPARATED list (--keys A,B, or the flag repeated) — passing the names as separate arguments makes the second one the command to exec, which surfaces asexec: "B": executable file not found in $PATH. - A key copy in
.hermes/.envcan differ from the Replit secret. When/tabsreturns 401 with a key you believe is correct, compare SHA-256 prefixes of both values (never print them) before blaming the server. - Serve on the published number — keep
localPortandexternalPortequal. The operator reads a6080 local → 3000 publicpair as a needless trap (mending lu ganti port ... biar ga bikin bingung): one service, two numbers to remember, and every client URL differs between here and elsewhere. Bind the app to the number you want to publish ({localPort = 3000, externalPort = 3000}) sohttp://127.0.0.1:<port>/vnc.htmlandhttps://$REPLIT_DEV_DOMAIN:<port>/vnc.htmlare the same shape; make the port a default in the runner (LLM_WIKI_UI_PORT:-3000) and change both sides together. A[[ports]]edit applies WITHOUT a repl restart (verified live), so flattening an existing mapping costs no downtime — flip the entry, restart the listener on the new port, then re-verify local and public.
A public port must appear in .replit [[ports]] with matching localPort and externalPort, and the server must bind 0.0.0.0 on that port. The URL always carries the port (https://$REPLIT_DEV_DOMAIN:<port>/) — only one port can be the default, so a bare domain hits 80 and 502s when nothing listens there. The platform OWNS that list — a hand-appended [[ports]] entry gets re-serialized away and the service then 502s while the file looks right, so register a port by starting the listener and loading https://$REPLIT_DEV_DOMAIN:<port>/ once, and trust the proxied response over the file. The router is HTTP(S)-only, so a non-HTTP protocol (sshd, a DB port) cannot be exposed this way at all. Serve a service on a port nothing else owns: two .replit workflows sharing a waitForPort cannot both bind, and the Hermes dashboard defaults to 8080, so a second service there silently loses the bind. Check the port owner before debugging the service: if /health answers with someone else's body (the dashboard returns 302 + {"error":"unauthenticated",...,"login_url":"/login"}), your listener never got the port — pick a free declared port and re-point every client at it. Details: references/exposing-ports.md.
- Start the server with its env loaded, never with a bare
node server.jsfrom a shell that has not sourced$XDG_CONFIG_HOME/camofox/env.sh. Without it the browser lacks the GTK/X11 library path and fails withbrowserType.launch ... libX11-xcb.so.1; every tab call then returns503 browser_launch_timeout. Load secrets in the same command, e.g.replit secrets exec --keys CAMOFOX_ACCESS_KEY,CAMOFOX_API_KEY -- sh -c '. $XDG_CONFIG_HOME/camofox/env.sh; node server.js', withCAMOFOX_BIND_HOST=0.0.0.0, and confirm the port is in.replit[[ports]]. - Stop a Camofox server that a
.replitworkflow manages through the workflow in the Replit UI. Akillofnode server.jsis undone by the workflow, which relaunches both the server andcamofox-browser-mcp. Kill by explicit PID only; apkill -fpattern can match the calling shell. - In a test, a 400 on an empty
/tabsbody means the gate passed. A503 browser_launch_timeoutis a server-side browser launch failure, separate from auth. - A first load of a Cloudflare-protected site can come back as the interstitial (
httpStatus 403, titleJust a moment..., Ray ID in the footer). Camoufox passes it on its own: issue the same navigate again after a few seconds and it returns 200 with the real page. Do not try to click Turnstile through the AX snapshot — it lives in a cross-origin iframe and never appears there. camofox_import_cookiesresolvescookiesPathagainst$(XDG_CONFIG_HOME)/camofox/cookiesON THE MACHINE RUNNING THE ADAPTER (notCAMOFOX_COOKIES_DIR, not~/.camofox), parses the file there and uploads it. A browser JSON export must be converted to Netscape first: strip the leading dot from the domain, keep httpOnly out of the#HttpOnly_prefix, and map a session expiry to a far-future timestamp; real exports then import clean (a 12-cookie Replit set opened the authed page first try), while a synthetic leading-dot cookie raises FirefoxNS_ERROR_ILLEGAL_VALUE.- Separate the gate's status from the target page's status. A gate rejection is the server's own JSON error:
{"error":"Unauthorized"}(401) or{"error":"Forbidden"}(403). A/tabscall that passes the gate returns 200, and the target page's status is in thehttpStatusfield (for example 404 or 403 from the site). ReadhttpStatusbefore blaming auth. - A cookie-import 403 with the correct client code means the running server's keys differ from the secret. The server reads
CAMOFOX_API_KEYandCAMOFOX_ACCESS_KEYonly at startup, so changing a Replit secret does nothing until the server restarts. Compare key lengths (never values) between the secret and the server's environment, then restart the server with the same values and retry. - A
.replitworkflow runscamofox.pyfrom its own path. After a fix merges, pull the clone that path points at; a local clone at an older commit runs the old code and reproduces a fixed bug. Checkgit log -1 -- <file>in that clone before debugging the code. env -iempties PATH, so a followingtimeout python3fails withNo such file or directory. Pass the absolute interpreter (PY=$(command -v python3)) when running in a clean environment.- Never assign
PATHa literal value in an agent shell. Terminal state persists across calls, soexport PATH=/one/dir:/usr/bin:/bin(to reach a single tool) silently drops the Nix and$XDG_BIN_HOMEentries for every LATER call:python3,replit,uvand the platform toolchain then fail withcommand not foundseveral steps after the edit, pointing nowhere near the cause. Append (PATH="$PATH:<dir>") or export into a file under$HERMES_HOME/cache/scratch/and. that-fileat the top of later calls. Recover by reading the platform PATH from a fresh login shell (bash -lc 'printf %s "$PATH"') or re-sourcing the rc. - Lost
replit? It is a Nix store path, not a stable location:/nix/store/*replit-cli-*/bin/replit(plus-runtime-path/binfor the platform toolchain). Re-derive it rather than concluding the CLI is gone. env -i bash -lcdoes NOT reproduce a Replit shell: it dropsREPLIT_BASHRC/REPLIT_MODE, so the rc chain never sources and the shell looks like it has no aliases or exports — a false failure. To test rc output, run a normal login shell with the variable under test stripped from the inherited PATH instead:PATH=$(echo "$PATH" | tr ':' '\n' | grep -v 'installs/.*/venv' | paste -sd:) bash -lc '...'.
Setup-script env rules (setup.sh, setup/rc.sh, setup/common.sh)
- Credentials are read from the environment, never baked into a script. The
user treats a token quoted into chat or hardcoded in an installer as leaked
(they rotate/revoke it right after). Take
TAILSCALE_AUTH_KEY=… setup.sh --tailscale --joinstyle input, and make everything after the first run work off the stored state so no second secret is needed. Printing a usage line with a placeholder (tskey-auth-...) is correct; printing the value is not. - Install and join are two flags, not one.
--tailscaleinstalls binaries and exits;--joinis the only path that writes sshd config/keys, binds$RS_PORTand runstailscale up.-jis--join;--workflow/-wis the same run in workflow mode. There is no prepare-only mode and no second flag to chain: if a box must be inspectable without listeners, stop at--tailscale.
Renaming a setup flag (strict — this skill is self-used)
The skill has one operator, so a rename is done hard: no alias kept, no migration block that
rewrites old .replit entries. Old args must fail loudly instead of drifting.
- Enumerate every surface in the SAME pass — a rename is not one
casearm: arg parse, usage text,ui_menu.shkey,cli.shstep name,cleanup.shtarget +clean_<fn>, theinstall_<fn>definition, the workflow-shim match string, docs, probe scripts. - Short and word aliases ride the flag they alias, not a new branch:
--join|-j,-w|--workflowsit in the SAMEcasearm, so the alias cannot drift. The help line lists all spellings — a hidden alias is a support call. - A clean target is named after the TOOL, never the verb.
--clean tailscaletears the whole join down (binaries + sshd + node state + the ssh wiring) because tailscale is what backs it;--clean joinonly reads like the install flag and misleads. One target, one owner, nothing left behind for a second call to find. - Feature names, not legacy prefixes. State dir
$RS_DIRis$REPL_HOME/.ssh/join-tailnetand the hop key tag isrepl-hop; the merge-erae2e-sshd/e2e-hopnames and the laterjoin-sshddir name are gone (the dir holds the whole tailnet-join state, not just sshd). Renaming the state dir mints a new sshd host key — oldknown_hostslines rot, so the clean sweeps[host]:$RS_PORTtoo, andclean_tailscalesweeps the legacy dir name. - Make the unknown-option arm
diewith the new spelling in the hint (die "Unknown option '$1'. … sudah digabung jadi --join"). Without it an unknown flag leaves everyINSTALL_*false and the guard at the bottom falls through toINSTALL_ALL— one stale workflow entry then re-installs the whole toolchain on every boot. git mvany renamed script/reference so history follows; update the pointers (header comment, SKILL.md row, sibling docs).bash -nevery touched script and runsetup.sh --help— the usage heredoc is an UNQUOTED<<USAGE, so a bare backtick in new help text executes as a command at--helptime (line 4: --tailscale: command not found). Escape it ```.- Prove it on a second box, not here: fresh
--tailscale --join, then a rerun WITHOUT the auth key must printsshd sudah hidup/tailnet: <ip>and stay joined (idempotent), and the old spelling must die with the hint. The uninstall leg runs detached (setsid … >& clean.log &+ poll the log for the last line) — it kills tailscaled, which drops the tailnet leg and the session with it.scripts/join-lifecycle-test.shruns the whole cycle that way and dumps the post-clean assertions (config/known_hosts/keys/state) to a file. - Push the updated tree BEFORE the clean leg. The clean kicks the box off the tailnet, so
the channel you would have used to
scpthe new scripts is the one that dies; a test that cleans the old copy leaves the box running old flags. Ship the tree, then clean. - Expect the lockout and stage the way back first. After a full uninstall nothing on the
box can be reached from here (sshd and tailscaled are both gone), so line up the recovery
line before starting —
cd ~/workspace && TAILSCALE_NODE_NAME=<node> bash skills/replit/scripts/setup.sh --tailscale --join, typed by the operator in the platform shell. It needs no auth key while the state dir survives; a clean that already wiped the state does needTAILSCALE_AUTH_KEY. - A rename of the state dir orphans the old directory. Nothing migrates, so
$REPL_HOME/.ssh/<old-name>/survives with a dead host key inside; delete it by hand once the new path is proven.known_hostslines for the old host key die on the same port, which is why the clean sweeps[host]:$RS_PORTrather than trusting host-key reuse.scpmay be absent — push the tree withtar czf - dir | ssh host 'cat > ~/x.tgz'and unpack there.
- Export only what the script owns. Replit already provides
XDG_CONFIG_HOME,XDG_DATA_HOME, andXDG_CACHE_HOME; re-exporting them adds nothing.XDG_BIN_HOMEis the one PATH entry the toolchain owns, always$REPL_HOME/.local/bin. HERMES_HOME,HERMES_MANAGED_DIRand the launcher dir are ORDINARY wire-registered tool wiring in the single managed toolchain block (wire_tool hermes+record_tool_env_vars/record_tool_path_dirs, mirrored ininstall_hermesandfix_derive_wiring). Never give a tool its own rc block outside the managed markers: a second block has to be re-appended after EVERY rewrite to keep PATH precedence stable,strip_tool_wiringcannot see it, and a run that does not install that tool silently drops its lines (the pre-mergehermes-toolsblock had all three problems;lift_legacy_hermes_wiring/strip_legacy_hermes_blockmigrate such an rc in one pass). The managed-scope dir defaults to/etc/hermes, which does not exist and cannot be created on Replit (/etcis a read-only mount, no root), so it is pinned under$HERMES_HOME/managed. KeepHERMES_HOMEOUT of_FIXED_RC_ENV_VARS: that list suppresses the tool-var export, which is now the only thing that emits it.- The only Hermes PATH entry is
$HERMES_HOME/hermes-agent/.hermes/bin(the stable launcher; it resolves the correct environment itself). Never addinstalls/*/environments/*/venv/binorhermes-agent/venv/bin: those venvs belong to an isolated session run, are not stable across sessions or updates, and a public shell that picks one up calls a binary from a session that may not exist. - A service you deploy here must not fall back to a Hermes-managed binary.
$HERMES_HOME/tools/*(node, python, ripgrep...) is Hermes' private install: it moves onhermes updateand disappears with--clean hermes, Hermes stripsLD_LIBRARY_PATHfrom child processes it spawns (MCP children, terminal children), and on this box those tarball binaries linklibatomic.so.1, which lives only in/nix/store. A wrapper that ends withcommand -v nodetherefore picks the broken one and the failure surfaces far away asConnection closedor a bare exit 127. Pick candidates explicitly ($VAR,$XDG_BIN_HOME/node,/usr/local/bin/node,/usr/bin/node), reject any path containing/.hermes/, and fail loudly with the reason instead of falling through — a silent fallback is what made this take three sessions to find. - Never put
$HERMES_HOME/tools/*on the user PATH either. Those dirs (node, npm, python, uv, gh, ffmpeg, ripgrep) are the agent's managed install:hermes updatereplaces them and an uninstall or--clean hermesdeletes them, so a global install in a user shell follows the binary into Hermes' prefix and dies with Hermes (npm i -g <pkg>→$HERMES_HOME/tools/npm-*/...),pipfollows Hermes' interpreter, and PATH order silently decides which node/python a user's script ran under. An rc block that mirrors them looks convenient and is a data-loss trap. User shells get the platform toolchain (.replitnodejs-24 / python3.13 modules +$XDG_BIN_HOME); Hermes runs its own binaries from its own PATH and does not read the rc. - Do NOT delete the user-bin launcher forwarders (
hermes,hermes-acp,hermes-agentin$HOME/.local/bin). Hermes publishes them itself (_launchers.py::expose_cli→_publish_conveniences) and re-creates them on every install/update, so removing them is futile; andhermes-agentexists ONLY as that forwarder (.hermes/binstageshermes+hermes-acponly), so removing it deletes the command outright. The forwarders exec the durable$HERMES_HOME/hermes-agent/.hermes/bin/*launcher, not an ephemeral path — check whether the framework owns and republishes a file before treating it as stale clutter. - Every tool's wiring has to exist in THREE places, or it is half-wired: the installer (
record_tool_env_vars/record_tool_path_dirs),fix_derive_wiring, and adoctorgate.fix_derive_wiringis what makes--fixable to rebuild the line on a box where that tool is present but is not being installed this run; without that branch--fixsilently cannot restore it, so the wiring survives only until something rewrites the rc. Gate the doctor check on the block's CONTENT (grep -qE '^export[[:space:]]+ZDOTDIR=' "$BASHRC"), never on a marker or on a file existing — a marker check passes while the export is gone. - Do not define
XDG_STATE_HOME. No script reads it, so it is dead config. - Point the TLS variables at the platform store, never at
$SYSTEM_CERTIFICATE_PATH.SSL_CERT_FILE,SSL_CERT_DIR,NODE_EXTRA_CA_CERTS,REQUESTS_CA_BUNDLE,GIT_SSL_CAINFOandAWS_CA_BUNDLEcome from/etc/ssl/certs/ca-certificates.crtwhen it is readable. The Nix cacert bundle has none of the Replit roots (0 Replit-named subjects vs 2 in the system bundle: "Replit internal proxy Root CA …", "Replit Localhost Development CA"), so wiring it in replaces a store that knows the proxy with one that does not — public hosts keep answering 200 while proxied ones fail — and being a/nix/storepath it can be GC'd into a missingSSL_CERT_FILE, which breaks every TLS client outright.NIX_SSL_CERT_FILEis deliberately not set: nothing we run reads it and nix resolves its own cache without it. - Never set
npm_config_dangerously_allow_all_scripts. It bypasses npm's install-script review for every install (upstream: "strongly discouraged"), including packages explicitly denied. npm ≥ 12 blocks unapproved dependency install scripts while npm 10/11 only warn, so the flag buys nothing on a box running npm 11 and silently disables the guard the moment npm 12 shows up. Allow scripts per project:allowScriptsinpackage.json, ornpm approve-scripts. - Generated
hermesaliases call barehermes, not$XDG_BIN_HOME/hermes. The managed block registers the real launcher dir ($HERMES_LAUNCHER_DIR=$HERMES_HOME/hermes-agent/.hermes/bin), which is the only provider ofhermes/hermes-acpon this box, so barehermesresolves there — or through Hermes' own$HOME/.local/binforwarder, which execs the same binary. A$XDG_BIN_HOME/hermesalias points outside both. - Verify generated rc output before committing: call the emitter directly (
emit_managed_blockwithREPLIT_MODE=true),bash -nthe output, grep for the removed names, then source the output in a clean shell and run each alias. A text check passes an alias that cannot run. - Prove idempotence and no-duplication, not just validity. Run the writer twice and require an identical
md5sum(a fixed point means the strip/append seam is clean and the file will not churn on every run), and in a clean shell source the rc TWICE counting each entry:printf '%s\n' "$PATH" | tr ':' '\n' | sort | uniq -c. An unguarded prepend doubles on the second source while a guardedcase ":$PATH:" in *":dir:"*)stays at 1 — a single-source check cannot see that difference, and on this box the rc IS sourced more than once per shell (platform bootstrap, theREPLIT_BASHRCshim, nested agent shells). - Double-quote any alias whose body uses a variable, so the variable expands at definition:
alias oc="$XDG_BIN_HOME/opencode". Single quotes keep$VARliteral, and the alias then fails when called. In a quoted heredoc (<<'EOF') write$VARbare; a backslash is written into the file as-is. - The block text in
emit_managed_blocksits in an UNQUOTED heredoc (<<EOF), so$VARexpands at emit time and backticks or$(...)inside it execute. A backtick pair in a comment (`npm i -g`) ranhermes/hermes-acpat emit time, which then hung the wholesetup.shrun waiting on stdin. Write emitted prose without backticks; escape (\$VAR) anything that must reach the file literally. Test the emitter against a COPY of the rc and with stdin closed (</dev/null) before running it for real: withBASHRCunset, the strip step'sawk "$BASHRC"reads stdin and blocks forever instead of failing. After an emit, grep the file for a stray backtick and the log forcommand not found— those are the fingerprints of a comment that ran (a stray`npm approve-scripts`in a comment is enough to makenpmexecute duringsetup.sh). - Test an alias from a sourced file, not on the line that sources it: bash parses the file's aliases only after that line. Run
shopt -s expand_aliases, thensource rc.sh, then the alias on its own line. - Registry settings in the emitted block are plain exports, not
${VAR:-default}, so the public registries win over a firewall value set earlier.PIP_TRUSTED_HOSTis also a plain export. - The emitted rc is for future shells — a run that itself installs npm packages must override the registry env IN-PROCESS too (common.sh at source time). On Replit the inherited
npm_config_registryis the internal HTTP mirror (http://package-firewall.replit.internal/...) and npm >= 12 refuses non-https, so npm-based installs (hermes, camofox) die during the very run that should install them; force the public https registries when the inherited value is nothttps://*. --fixruns thesetup/code of the checkout it is launched from. Run it from the branch under test. Amaincheckout re-emits the old block, so the live.config/bashrcwill not show a fix that is still in review.
Camofox tuning
There is no env-file template to ship — the interface is exported vars
(CAMOFOX_PORT, CAMOFOX_ACCESS_KEY, TAB_INACTIVITY_MS, ...), read from the
shell (or exported before camofox-browser):
CAMOFOX_PORT=9000 CAMOFOX_ACCESS_KEY="$(openssl rand -hex 32)" camofox
The Camofox tunables (with defaults) are documented in references/camofox.md.
Installing this skill pack
One skill, one command:
npx -y skills add skynight137/skills -s replit