Specs and Command Lines
Add flags to a command, or a spec to a part. A spec is a thing written down as a TOML file, and boxes, sandboxes and agents have one each: the box spec, the sandbox spec and the agent spec.
# every command's flags come from one type, its help from src/local/help.toml
cargo test -p link-e2e --test e2e flags::
cargo test -p link-e2e --test e2e help::
# the spec cases, src/tests/specs/<group>/, and every spec the docs show
cargo test -p link-e2e --test e2e specs::
cargo test -p link-e2e --test e2e docs::
lnk box spec --cpus 4 --disk 40GB # the spec those flags make
lnk sandbox spec --spec app.toml # a spec read, and printed backAdd a command's flags
Every lnk command takes its arguments as one type, whether they come
from flags, a file or a test, so the command line does nothing a file
can't say.
#[derive(clap::Args, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Share {
#[arg(required = true)]
pub paths: Vec<String>,
#[arg(long, default_value = "7d")]
#[serde(default = "seven_days")]
pub expires: String,
#[arg(long, env = "LNK_AUTH", hide_env_values = true)]
#[serde(skip)]
pub auth: Option<String>,
}- Put the type in the plugin's lib, in its
climodule, and make the command its variant, as inShare(Share). The command's code takes the type. - Give each field its help in
src/local/help.toml, at the command's table, as in"--expires" = "How long the link works: ...". Never use a doc comment (A command's help). - Name the field what the flag is, so
--dry-runisdry_run. A renamed flag keeps its old name as a hidden alias for a release. - Leaving a field out of a file means what leaving its flag out does.
Use
#[serde(default)]on a switch or a list, and the same default value on both sides. - A secret taken as a flag, or anything that names an account
(
--token,--key,--auth,--key-id), is#[serde(skip)], so it is never in a file and never printed. - List the type in the module's
types(), and a new plugin inchecked()ofsrc/tests/e2e/tests/flags.rs.
flags.rs fails on a command whose flags aren't one such type, on a flag
not named as its field, on a file of the same values that makes other
arguments, and on a field named like a secret (token, key, secret,
password, auth) that serde doesn't skip.
A command that takes a spec's flags, such as lnk box start or lnk sandbox run, holds the spec in a field, fields, which a file writes as
the spec does. Its type says where each spec flag is in it, as in
typed::<Start>("box start").with_spec(spec_key), where --cpus is at
fields.machine.cpus. The spec's own test checks those flags' names.
Add a spec
A spec is the type of a command whose command line you'd write down,
published. --spec <file> reads it, and lnk <group> spec prints one.
Every spec follows these rules, and its own reference page says only what
its fields are and mean.
-
TOML, read strictly (
link_plugin::spec::parse). An unknown key or table, an empty table, or two fields in one table where one would be ignored is an error naming the line. Values are typed, so a version is a version, a name has a name's characters and a word from a list is one of its words. Nothing from a file reaches a shell or a cloud's CLI as written. -
Missing means least. A field left out is the narrowest access and the lowest cost that still does the job, and an empty file is the least thing that works.
["*"]asks a list for everything of its kind, so wide open is always written down. There are two exceptions, a default that asks you first, such as a box's login, and a limit that only paces what a grant already bounds, such as a sandbox'sper_minute. -
One type is the file and the flags. It lives in the plugin that owns the thing, or in
link_pluginbeside its contract when another plugin keeps one, as the harness keeps a sandbox's spec for each agent. A flag is its field's name, with these rules:- Prefix it with its table where two tables share a name
(
--internet-host,--lan-host). - Make it singular and repeated for a list (
--tag). - Use the table's own name where the field only lists the table's
things, so
[tools] namesis--tool. - Call a top-level field whose name a command argument already has
--spec-<field>, as in--spec-nameand--spec-version. - Call a switch that is on unless written
--no-<field>, so[lnk] loginis--no-login.
A test fails on a field with no flag or a flag with no field.
- Prefix it with its table where two tables share a name
(
-
No accounts, secrets, or what Link picks per run. Which account, which box and the upstream are the command's arguments. They are the only flags beside the spec's, along with
--spec,--yesand--json. A spec still names what belongs to whoever runs it, such as a sandbox's tools, ports and network. So a spec handed on means the same grants and not the same reach, and a grant that can't mean anything fixed on a machine is refused there. -
One plugin, one spec. A spec describes only what its plugin owns, and never runs anything. Commands combine things, never a file: a box's spec has no agents, and a sandbox's no setup script.
-
A flag, then the target's table, then the default table, then Link's default. A table for one target is
[<table>.<target>], such as[machine.aws]or[commands.macos], and it replaces the default table's fields, each whole. A list that takes away, such as a sandbox'shide, adds up across all of them instead, so nothing written to close a thing is dropped. -
Values a person can read. Sizes are strings with their unit, and
"8 GB"means GiB. Ranges are{ min, max }, or2..8on the command line. Paths are relative to the spec's folder or start from~/, and names beat numbers wherever the thing has one. -
Name and version at the top.
nameandversionare top-level keys, andversionis an integer that is yours to bump. Everything else is in tables, and whatever a spec makes remembers the version it was made from. -
A round trip.
lnk <group> spec <thing>prints the file that makes that thing again, with paths absolute and anything Link picked pinned. It prints from what Link and the cloud know, never from a file the thing could write, and through thetomlserializer (link_plugin::spec::print). With flags and no thing, it prints the spec they make. A command shows what a spec would do and does nothing, aslnk box sizesandlnk sandbox checkdo. -
A spec may be someone else's. Its paths, versions and grants are checked when it's read, and one you haven't run is shown before it runs. A spec is never kept where what it makes can write it.
Test a spec with files
A test case is a file in src/tests/specs/<group>/ that holds the spec
as a user writes it, and what must come of it. A new bug is a new file,
not new test code.
# src/tests/specs/sandbox/openai-only.toml
[spec]
name = "openai-only"
[spec.internet]
hosts = ["api.openai.com"]
[spec.commands]
allow = ["curl"]
[expect]
reaches = ["api.openai.com"]
refused = ["example.com", "api.openai.com:80", "192.168.1.1", "127.0.0.1:22", "169.254.169.254:80"]
runs = ["curl"]
refused_commands = ["sh", "ls"]One runner per group reads every case. sandbox_specs.rs runs lnk sandbox check --spec on it, and box_specs.rs runs the box plugin's
matching against a fake cloud's sizes, with [expect] size = ... or
error = .... A spec that must be refused is a case too, and its
[expect] invalid holds the words of the error it gets.
Every toml block the docs and the website show is read by its type, so
a page never shows a file Link refuses. A box's pages hold box specs and
the sandbox's hold sandbox specs. A block elsewhere is one of those, an
agent's spec or a team's, or a case whose first line is its path, like
the one above.
An agent's spec differs from the rules above in one way, because
missing means Link's and not least. It holds opinions and not grants,
and Link's own are a spec of their own beneath it, so a key left out is
Link's, and extends = "none" starts from nothing. Its cases in
src/tests/specs/agent/ give the agent's own spec, its teams' files and
the union that must come of them (agent_specs.rs).
Decisions says why it works this way.
