Developing Link, from build to test to ship
Build lnk from source, test it, and ship a change to dev. Where new
code goes is in Architecture.
Build and use it
cargo build in the public workspace builds lnk and the core's
plugins into target/debug, and that lnk runs the plugins built next
to it.
cd link-public
cargo build
target/debug/lnk --help # the groups, and the commands of lnk itself
target/debug/lnk plugin list # all installed: lnk runs the plugins built next to it
target/debug/lnk --link-registryInstall lnk puts lnk on your PATH, from source or a
release. To see a tunnel work, with the demo or by hand, see Run it
locally.
Run it locally
You need Rust and curl. Rustup installs the version the repository
pins, from its rust-toolchain.toml, at your first cargo. The checks
before a push also need shellcheck, and on Linux the sandbox's tests
need bubblewrap and openssh-server. The quickest way is the demo:
# builds, starts the demo server, relay and agent, then curls /, /stream and /echo
scripts/demos/demo.shTo run the pieces by hand, start each in its own terminal, or in the
background with logs in a temp folder. With no users file, the relay
runs in local dev mode, with a single user, dev, and a built-in token
that the agent uses by default. Local URLs then look like
<app>.dev.localhost:7080. A real deployment uses a users file, and the
relay refuses to start without one unless it runs on localhost.
export NO_PROXY='*'
# Build only the programs this example starts
cargo build -p link-cli -p link-plugin-tunnel -p link-relay-server -p link-demo-hello-server
# 1. an app to expose, on localhost:8080: any web server works, such as next dev -p 8080
target/debug/link-demo-hello-server
# 2. the relay, on 127.0.0.1:7080: in production this runs on a server
target/debug/link-relay
# 3. the agent: it uses https://local-link.xyz unless told otherwise
export LNK_RELAY=http://localhost:7080
target/debug/lnk tunnel open 8080 --public --name demo # without --name, a random app name
# 4. the internet, well, you
curl http://demo.dev.localhost:7080/
# chunks arrive as they are produced
curl -N http://demo.dev.localhost:7080/stream*.localhost resolves to 127.0.0.1 in curl and in browsers. If a tool
doesn't resolve it, send the Host header yourself with curl -H 'Host: demo.dev.localhost:7080' http://127.0.0.1:7080/. With an HTTP proxy, add
.localhost to NO_PROXY.
What to expect
Here is what to expect, and what to try:
- Relay root (
localhost:7080): 404 - Unknown tunnel, or the agent stopped: 502
tunnel demo.dev is not connected - Local server stopped: 502
could not reach localhost:8080 - Wrong token: Agent exits with
relay rejected agent: invalid token - Relay restarts: Agent reconnects with backoff (1s, 2s, 4s … up to 30s) and gets the same URL back
lnk tunnel open 8080 --name demo --auth, answeringfriend:secret: A login prompt, andcurl -u friend:secret …gets throughlnk tunnel open 8080 --account you@example.comon a relay without an account service: Refused: the relay can't sign visitors in.visitors.rstests sign-ins with a stand-in account service/wson the demo server: WebSockets pass through (it echoes every message), so dev-server hot reload works- A model (models): With a local relay the URL is
http://<app>.dev.localhost:7080/v1
The demo server (link-demo-hello-server, on :8080) answers /,
/echo, /headers, /stream and /ws (a WebSocket echo).
Test
Tests run the workspace's programs from target/debug, so build them
first. Local services and fakes need no paid account.
# The core
cargo test
# Every package: its programs, then its tests
cargo build --workspace --bins
cargo test --workspace
# Every folder and its docs, against every rule, in seconds
cargo test -p link-docs-check -p link-docs-check-codeFast checks are the goal: every change waits on them. Measure expensive
tests and preserve the behavior they prove when reducing their cost.
A library builds doc tests only when its docs
hold a Rust example: one with none sets doctest = false under [lib],
and the first example added takes it out. The docs checks' doc-tests
rule holds both.
Which packages build
The public workspace's default members are the core. Select another
package with -p <crate>, or every public member with --workspace.
Cargo resolves features for the selected packages. Expanding a selection
can compile a shared dependency again with additional features. A core
build never compiles a connection, harness adapter or the tools' document
extractors.
Measure test execution
Use Test Timing to turn saved Cargo output into a plugin breakdown and a standalone chart. Keep the test command's status and machine settings with the report. Compilation and test execution are separate costs, and a failed or partial run is not a complete performance baseline.
A test's home, services and Keychain
A test that runs lnk gives it a temporary HOME,
LNK_SERVICES=off and LNK_KEYCHAIN=off, or a fake security through
LNK_SECURITY. A service's or a Keychain item's name doesn't depend on
HOME, so without them a test on a Mac stops or replaces your agent and
writes your Keychain.
The sandbox on Linux
The sandbox's tests need bubblewrap, as Gotchas says. On
Linux with bubblewrap, the agent tests run the mini harness in the
sandbox, with its endpoint served out by its proxy (bridge.rs).
Debug information
Every workspace owns one profile;
CARGO_PROFILE_DEV_DEBUG=true enables debug info for a build, or cargo's
--config 'profile.dev.package."<name>".debug=true' for one package.
Write a test
Test new behavior in tests/e2e/tests/tunnel.rs, with the helpers
every end-to-end file shares (tests/e2e/tests/common/relay.rs):
start_local_serviceruns the demo server on a random port.start_relaystarts a relay with usersdanaandalice, andstart_relay_with(|config| ...)changes its settings.start_agent(relay, port, Some("id"))starts an agent, andstart_agent_with(relay, service, id)does it for anyService.connect(agent_config(relay, token, name), service)takes any settings and returns the relay's refusal.fetch(relay, host, path)sends a request, andhost_of(url)gives a tunnel's host.
Unit tests live next to the code in each crate. The end-to-end tests send
requests to 127.0.0.1 with a Host: <app>.<user>.localhost:<port>
header, so they need no DNS. A few rules keep them working:
- A test owns its environment inputs. Give a child process the values
it needs with
Command::envand remove optional ones withCommand::env_remove. Do not assume a login shell has setUSER,LOGNAME,HOMEor an XDG variable. Test an optional value absent, empty and misleading when the behavior depends on it. Change a child's environment, never the test process's shared environment. - A test with a fake launcher uses a fixed username such as
test-user. That account need not exist. Only tests exercising real OS identity look up the kernel's UID (geteuidfor the effective user), resolving it through the account database orid -unwhen a name is required.USERandLOGNAMEare optional, caller-controlled strings. - A new end-to-end file is a module of one test program. Add
mod <file>;totests/e2e/tests/main.rsand reach the helpers withuse crate::common;(why). The sandbox's, the harness plugin's, Link Harness's and Link Memory's tests are one program each too: a new file there is amodin itstests/main.rs, and its temp folders' names begin with the file's. - A test that runs
lnkor a plugin gets them fromlink_test_kit::programs(), this workspace'starget/debug(why), and one from another workspace fromprograms_in. - A first test in a plugin's
main.rsneeds the plugin'stest = falseremoved from itsCargo.toml. - A test that runs cargo uses
link_test_kit::cargo(), because any other way recompiles the workspace (why). - A test reaches its helpers through the testing kit,
link-test-kit, a development dependency, and never writes one of its own: the kit's page lists each helper and the raw call it replaces. - A test's temporary folder is a
link_test_kit::TempDir, removed when the test ends, failed or not (why). - On Linux, when killing a controller to check its child's lifetime, use an isolated subprocess as a subreaper and wait for the adopted child. Signal zero also succeeds for a zombie, so it cannot prove the child still runs. Keep process-wide subreaping out of concurrent tests. For asynchronous retirement, wait with a deadline for the actual cleanup; a published final record can precede it.
- A test whose sandbox's proxy reaches a host by name gives the name an
address with
LNK_TEST_RESOLVE(example.com=192.0.2.1), so it passes on a machine without DNS, behind an HTTPS proxy alone (Claude Code on the web). The name never leaves the machine: the test's own upstream takes the connection. - A test whose sandbox's proxy speaks TLS to a host of its own (a fake
git host) gives the proxy that host's certificate authority with
LNK_TEST_CA(a PEM file), and reaches it through an upstream of its own (lnk sandbox run --upstream http://127.0.0.1:<port>, a CONNECT proxy), assandbox'srun::a_sealed_token_clones_with_git_and_stays_outsidedoes.
Gotchas
Keep the checkout outside /tmp, which sandbox specifications cannot read.
Use Agent Sessions for shared setup, build limits, credentials
and the sandbox's platform requirements. A missing sandbox capability is
reported explicitly; it does not establish sandbox coverage.
Background services and the Keychain
Tests that start background services or use the macOS Keychain give them isolated settings and disable user services. Use the shared test helpers to write programs, stop process groups and remove temporary directories. See Decisions for the helper contracts.
Before you push
Pull requests into dev get no CI, so run these yourself, and all of
them must pass:
cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo build --locked --workspace --bins
cargo test --locked --workspaceTwo checks they don't cover: the sandbox's unit tests on the release's libc, and shellcheck:
# on Linux, after a change to the sandbox: its unit tests on the libc of the release
# with musl-tools, and rustup target add x86_64-unknown-linux-musl
cargo test --locked --target x86_64-unknown-linux-musl -p link-plugin-sandbox -p link-sandbox-confine -p link-sandbox-proxy --lib
git ls-files '*.sh' | xargs shellcheckchecks.yml also runs the sandbox's unit tests on arm64 Linux, which
has a release of its own. After a change to the sandbox, you can run
them on x86_64 Linux under qemu-user. That needs sudo apt-get install qemu-user clang and rustup target add aarch64-unknown-linux-musl:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUNNER=qemu-aarch64 \
CC_aarch64_unknown_linux_musl=clang CFLAGS_aarch64_unknown_linux_musl=--target=aarch64-linux-musl \
cargo test --locked --target aarch64-unknown-linux-musl -p link-plugin-sandbox -p link-sandbox-confine -p link-sandbox-proxy --libcargo test checks a change to the docs. The website's own checks, and
translating the docs, are on The
Website.
Test the server deploy scripts
The scripts in plugins/networking/relay/deploy (Run the Relay)
can't run fully here, since there's no systemd, apt or DigitalOcean, but
most of their logic can.
shellcheckthem, the scripts infiles/included.- For
files/link-relay-update, serve a fake release directory withpython3 -m http.server, then pointLNK_RELEASES_URL,LINK_RELAY_BINandLINK_UPDATE_STATEat scratch paths. Put a stubsystemctlfirst onPATHwhoserestart link-relaystarts the binary on :7080. This covers install, no-op, checksum failure and rollback, andtests/e2e/tests/updater.rsdoes some of it. - For
files/link-state, run it withLINK_STATE_ROOT=<dir> LINK_NO_RESTART=1. - For
create-droplet.shandrebuild.sh, stubdoctl,sshandscponPATHand check the calls they make.
Ship it
A change goes on a branch of its own off dev, and into dev by a pull
request.
git checkout -b my-change origin/dev
# commit, push, open a PR into dev, fill in Release notes in the templateTry committed changes on a cloud machine with
Developer Boxes.
A release is cut from dev into main, as Branches and
Releases says.
More
The pages below hold the detail this one leaves out.
- Architecture: the core and its plugins, and where new code goes
- Conventions: the rules every change follows
- Writing Docs: how a doc is written, where it goes, and its limits
- Agent Sessions: shared Claude and Codex setup and builds
- Testing Kit: the helpers every test uses, and what each replaces
- Test Environments: the rules that keep a test correct on every machine it runs on
- Skipping Tests: when a test skips, how, and every variable that decides it
- Test Troubleshooting: what a failing run says, and its cause and fix
- Write a Plugin: adding a plugin, harness, connection or cloud
- Plugin Contracts: what each
lnk ... --jsonprints - Specs and Command Lines: adding a command's flags, or a spec, and its tests
- The Protocol: the relay's wire protocol and login
- Decisions: why it works this way
Docs
- Agent Sessions: Start a Claude or Codex session with the same instructions and build tools
- Conventions: The rules every change follows, from the first commit to the release
- Install lnk: Get
lnkfrom a release, from source with cargo, or straight from a build, and switch between them without losing your agents or files - Write a Plugin: Add a plugin, a harness, a memory engine, a connection or a cloud to Link, with its code in the plugin it belongs to
- Specs and Command Lines: Add flags to a command, or a spec to a part
- Decisions: Why the code and its tests work the way they do
- Troubleshooting: What to do when installs of
lnkmix, a plugin won't install, or a pasted command misreads (Installlnk)