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-registry

Install 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.sh

To 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, answering friend:secret: A login prompt, and curl -u friend:secret … gets through
  • lnk tunnel open 8080 --account you@example.com on a relay without an account service: Refused: the relay can't sign visitors in. visitors.rs tests sign-ins with a stand-in account service
  • /ws on 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-code

Fast 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_service runs the demo server on a random port.
  • start_relay starts a relay with users dana and alice, and start_relay_with(|config| ...) changes its settings.
  • start_agent(relay, port, Some("id")) starts an agent, and start_agent_with(relay, service, id) does it for any Service. connect(agent_config(relay, token, name), service) takes any settings and returns the relay's refusal.
  • fetch(relay, host, path) sends a request, and host_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::env and remove optional ones with Command::env_remove. Do not assume a login shell has set USER, LOGNAME, HOME or 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 (geteuid for the effective user), resolving it through the account database or id -un when a name is required. USER and LOGNAME are optional, caller-controlled strings.
  • A new end-to-end file is a module of one test program. Add mod <file>; to tests/e2e/tests/main.rs and reach the helpers with use 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 a mod in its tests/main.rs, and its temp folders' names begin with the file's.
  • A test that runs lnk or a plugin gets them from link_test_kit::programs(), this workspace's target/debug (why), and one from another workspace from programs_in.
  • A first test in a plugin's main.rs needs the plugin's test = false removed from its Cargo.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), as sandbox's run::a_sealed_token_clones_with_git_and_stays_outside does.

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 --workspace

Two 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 shellcheck

checks.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 --lib

cargo 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.

  • shellcheck them, the scripts in files/ included.
  • For files/link-relay-update, serve a fake release directory with python3 -m http.server, then point LNK_RELEASES_URL, LINK_RELAY_BIN and LINK_UPDATE_STATE at scratch paths. Put a stub systemctl first on PATH whose restart link-relay starts the binary on :7080. This covers install, no-op, checksum failure and rollback, and tests/e2e/tests/updater.rs does some of it.
  • For files/link-state, run it with LINK_STATE_ROOT=<dir> LINK_NO_RESTART=1.
  • For create-droplet.sh and rebuild.sh, stub doctl, ssh and scp on PATH and 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 template

Try 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.

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 lnk from 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 lnk mix, a plugin won't install, or a pasted command misreads (Install lnk)