diff --git a/src/content/docs/admin/configuration.md b/src/content/docs/admin/configuration.md index d68b787..ed056b9 100644 --- a/src/content/docs/admin/configuration.md +++ b/src/content/docs/admin/configuration.md @@ -20,8 +20,8 @@ module "spin" { } ``` -When the control plane starts, it creates each item of the file that the installation does not -have yet: a setting, a preset, a network policy. From then on, the dashboard is where they change. +When the control plane starts, [it creates each item of the file that the installation does not +have yet](): a setting, a preset, a network policy. From then on, the dashboard is where they change. When the file later says something different about an item, the control plane does **not** write over what an administrator decided. It records the difference as a **proposal**, and the dashboard diff --git a/src/content/docs/admin/hosts.mdx b/src/content/docs/admin/hosts.mdx index fece0e6..44c2d20 100644 --- a/src/content/docs/admin/hosts.mdx +++ b/src/content/docs/admin/hosts.mdx @@ -9,8 +9,8 @@ reads: import Value from '../../../components/Value.astro'; -Runners are also called **hosts**. An administrator manages them in **Admin → Runners** or with -`spin admin runner`. For how runners join, scale and go away, see +Runners are also called **hosts**. [An administrator manages them in **Admin → Runners** or with +`spin admin runner`](). For how runners join, scale and go away, see [Runners and hosts](/concepts/runners/). ## Seeing them @@ -20,7 +20,8 @@ spin admin runner list spin admin versions # the control plane's release, and each runner's and its machine's ``` -A runner's page in the dashboard shows its state, its capacity and its workspaces. +`spin admin versions` says [what is running where](). A runner's page in the dashboard shows its +state, its capacity and its workspaces. ## Drain and activate diff --git a/src/content/docs/admin/observability.md b/src/content/docs/admin/observability.md index 1a9c126..44e60a3 100644 --- a/src/content/docs/admin/observability.md +++ b/src/content/docs/admin/observability.md @@ -29,16 +29,16 @@ stops, so a machine replaced by an update takes nothing with it. | Traces | 7 days | | Metrics | 30 days | -Retention and whether workspaces' console output is collected are +Retention and [whether workspaces' console output is collected]() are [installation settings](/admin/configuration/). ## Reading them | Where | What | |---|---| -| **Admin → Logs** | Search the control plane's, the proxy's and the runners' logs | +| **Admin → Logs** | [Search the control plane's, the proxy's and the runners' logs]() | | **Admin → Traces** | Find a trace, such as one workspace's start from the request to the guest (`span_name:workspace.start`) | -| **Admin → Metrics** | Grafana, over the metric store, inside the dashboard | +| **Admin → Metrics** | [Grafana, over the metric store, inside the dashboard]() | | A workspace's **Logs** tab | What its machine printed | | `spin admin logs` | The same search as Admin → Logs, from the CLI | | `spin workspace logs ` | A workspace's console, from the CLI | diff --git a/src/content/docs/admin/operations.md b/src/content/docs/admin/operations.md new file mode 100644 index 0000000..d5fd616 --- /dev/null +++ b/src/content/docs/admin/operations.md @@ -0,0 +1,134 @@ +--- +title: Jobs, the bill and the store +description: What an administrator runs and reads beyond runners, from the control plane's jobs and the month's bill to the store, disk keys and a root shell. +reads: + - "spin:cmd/cli/cmd/admin_jobs.go@v20261110.01" + - "spin:cmd/cli/cmd/admin_costs.go@v20261110.01" + - "spin:cmd/cli/cmd/admin_storage.go@v20261110.01" + - "spin:cmd/cli/cmd/admin_volume.go@v20261110.01" + - "spin:cmd/cli/cmd/admin_shell.go@v20261110.01" +--- + +These are the `spin admin` commands that are not about a runner's state. For drain, activate and +revoke, see [Operating hosts](/admin/hosts/). + +## Jobs + +The control plane does periodic work as jobs. You can +[list the jobs and run one now](). + +```bash +spin admin jobs list +spin admin jobs run +``` + +The list says how often each job runs and how its last run went. `run` does not wait for the job: +read the list again to see how it went. A job that is already running is refused. + +## The bill + +`spin admin costs` shows +[the month's bill, its forecast and each workspace's share](). + +```bash +spin admin costs +spin admin costs --month +spin admin costs by workspace +spin admin costs by team +spin admin costs by owner +``` + +The first form is the cloud's bill by service, what the month is forecast to end at, and its +budget. It is the bill as the control plane last read it, never the cloud itself. The control +plane reads only the costs that `bill_scope` names in the +[configuration file](/reference/configuration/file/): without a scope it reads none. + +`by` shares the month out. The runners are shared by the cores and memory each workspace held, +and the volumes' bucket by what its volumes kept. What nobody caused is said apart and never +spread: runner capacity nobody held, bytes no workspace keeps, and the platform's own cost by +service. + +## The store + +`spin admin storage` shows +[every volume in the store and whose it is, and removes what nothing needs](). + +```bash +spin admin storage list +spin admin storage explain +spin admin storage untag-image +spin admin storage remove +``` + +`list` says whether each volume may be removed, as the `storage-gc` job last read the store. +`explain` says what a key copied from the bucket's console is, and whose. + +`remove` takes out an image, an extension release, an unclaimed volume or an orphan, and destroys +its key. An image its ref still names is untagged first. A workspace's disk, the newest release +of an extension, and a release a workspace runs are refused. + +## Re-keying a disk + +When a key is assumed taken by a host that held it, you can +[re-key the workspace's disk](). + +```bash +spin admin volume rekey +spin admin volume rekeys +``` + +`rekey` copies the disk whole under a key of its own, points the workspace at the copy and deletes +the old disk. That destroys the old key, and every snapshot of the disk with it. The workspace +is anybody's, named by its id, and must be stopped or quarantined. A disk something was cloned from is refused. `rekeys` lists the +disks being re-keyed, and the ones a revoked host's keys marked for it. + +## A root shell on a runner + +An administrator can open +[a root shell on a runner, for a while and with a reason](). + +```bash +spin admin shell open --reason "" --minutes +spin admin shell open --reason "" --incident +spin admin shell list +spin admin shell approve +spin admin shell close +spin admin shell recording +``` + +The runner is emptied first: each workspace is suspended and resumed on another runner. With +`--incident` the shell opens on the runner as it is. Where the installation sets +`host_shell_needs_approval`, another administrator approves the shell before the runner is +touched. Every session is recorded by the control plane and kept in the logs bucket under Object +Lock. + +Once the shell has ended, the runner is retired: it is revoked, every disk it held the key of is +marked to be re-keyed, and its machine is replaced by its group. + +## A workspace's bandwidth + +An administrator +[changes a running workspace's bandwidth, and its open connections stay open](). + +```bash +spin workspace bandwidth # megabits a second, each way; 0 lifts the limit +``` + +A stopped or suspended workspace takes the value at its next start. The limit bounds what a +workspace can cost in egress, so its owner may not raise it. + +## Writeback on a host + +A runner's policy can carry +[a writeback bound of its own, measured on that host first](). + +```bash +spin admin runner policy +spin admin runner policy --writeback-limit-bytes --writeback-background-bytes +``` + +With no flags the command shows the policy. Any flag writes the whole policy: a setting you do not +pass goes back to the runner's own default. Thresholds that no preset names are refused until +that host has been tested with them. The test is +[TestHostWriteback](/reference/api/hostservice/#testhostwriteback), and it loads the host's disk +while it runs. diff --git a/src/content/docs/admin/users.md b/src/content/docs/admin/users.md index b0a8694..d9b43ab 100644 --- a/src/content/docs/admin/users.md +++ b/src/content/docs/admin/users.md @@ -9,19 +9,19 @@ reads: ## Signing in -A user signs in to the dashboard with: +[A user signs in to the dashboard with](): - **GitHub**, or - **any OIDC provider**: your company's identity provider, Google, Microsoft Entra ID, Okta and others that speak OpenID Connect. -Providers are configured in **Admin → Security**. A provider can be the installation's, or a -team's own. Spinbox has no multi-factor authentication of its own: require it in your provider. +Providers are configured in **Admin → Security**. [A provider can be the installation's, or a +team's own](). Spinbox has no multi-factor authentication of its own: require it in your provider. The first administrator signs in with a password, from the one-time password the install wrote. Once a provider works, password sign-in can be turned off. -The CLI signs in through the dashboard, with a code the user approves (see [The CLI](/use/cli/)). +The CLI signs in through the dashboard, [with a code the user approves]() (see [The CLI](/use/cli/)). Sessions last seven days by default. ## Who may register @@ -46,7 +46,7 @@ adds. | Role | Can | |---|---| -| Owner | Everything, including deleting the team. One per team. | +| [Owner]() | Everything, including deleting the team. One per team. | | Admin | Manage members and the team's settings | | Member | Have workspaces in the team | @@ -74,6 +74,6 @@ the `keys:delegate` scope, each holding no more than it does. See ## The audit log -**Admin → Audit** lists who did what: sign-ins and failed sign-ins, sessions revoked, workspaces +**Admin → Audit** [lists who did what](): sign-ins and failed sign-ins, sessions revoked, workspaces created, started, stopped, recovered and deleted, changes to teams, to settings and to users, and runners registered. Nothing in the API can change or delete an entry. diff --git a/src/content/docs/concepts/network.mdx b/src/content/docs/concepts/network.mdx index e1bfb45..88fa5d1 100644 --- a/src/content/docs/concepts/network.mdx +++ b/src/content/docs/concepts/network.mdx @@ -63,7 +63,7 @@ allows them all. **DNS follows the policy.** A workspace's resolver is the runner's, so no policy needs to open port 53. A DNS question leaves from the runner, where no firewall rule sees it, and reaches whoever -answers that name, so the resolver only takes up the ones a workspace's policy names: +answers that name, so [the resolver only takes up the ones a workspace's policy names](): - A workspace with no tag, or with a rule that reaches every address (ports with no destination, or `0.0.0.0/0`), resolves anything. DNS gives it nothing it could not already do. @@ -89,12 +89,13 @@ spin workspace policies # the policies you can choose f spin workspace retag api --network-tag web --network-tag agent ``` -The owner can choose among the policies the installation declares. Removing a policy from a +The owner can choose among the policies the installation declares. A team declares +[policies of its own]() where the installation allows it (`team_network_policies`). Removing a policy from a running workspace closes the connections it had open, not only new ones. Administrators declare policies in the [installation settings](/admin/configuration/) or in the -dashboard (Admin → Network), which shows for each one whether every runner enforces the -current version. +dashboard (Admin → Network), [which shows for each one whether every runner enforces the +current version](). ## Seeing what happened @@ -102,7 +103,7 @@ current version. spin workspace egress api ``` -shows what the workspace reached in the last six hours, with the most traffic first, the names +shows [what the workspace reached in the last six hours](), with the most traffic first, the names it looked up that failed, and what was refused in the last hour, with the name it was looked up by. The workspace's **Network** tab in the dashboard shows the same. When something does not work, the refused list tells you which policy is too narrow. diff --git a/src/content/docs/concepts/ports.md b/src/content/docs/concepts/ports.md index 88dca1b..2252c12 100644 --- a/src/content/docs/concepts/ports.md +++ b/src/content/docs/concepts/ports.md @@ -7,8 +7,8 @@ reads: - "spin:cmd/cli/cmd/share.go@v20261108.01" --- -A service listening on a TCP port in a workspace can be reached through the installation's -proxy, at its own HTTPS address: +A service listening on a TCP port in a workspace [can be reached through the installation's +proxy, at its own HTTPS address](): ``` https://-.ws. diff --git a/src/content/docs/concepts/projects.md b/src/content/docs/concepts/projects.md new file mode 100644 index 0000000..556a42c --- /dev/null +++ b/src/content/docs/concepts/projects.md @@ -0,0 +1,116 @@ +--- +title: Projects and repositories +description: How a project ties a repository to the shape of its workspace, and how you open one from a URL, prebuild its refs and connect your GitHub account. +reads: + - "spin:cmd/cli/cmd/project.go@v20261110.01" + - "spin:cmd/cli/cmd/project_prebuild.go@v20261110.01" + - "spin:cmd/cli/cmd/open.go@v20261110.01" + - "spin:cmd/cli/cmd/git.go@v20261110.01" +--- + +A project is [a repository and the shape of the workspace that opens it](). +The shape is the image, the preset and the network policies. The project also names the ref a +workspace opens at. + +The guest of a workspace made from a project clones the repository into +`/workspace/` and runs the repository's own `.spin.yaml` tasks there. + +## Making and sharing a project + +Projects belong to the installation. Whoever makes one is its admin. An installation can keep +making projects to its administrators, with `project_creation` in the +[configuration file](/reference/configuration/file/). + +```bash +spin project create --name --repository --ref --preset +spin project list +``` + +Everybody signed in sees a project until its admins share it with only some people and teams. +A share is saved whole: what you do not name is no longer shared. Admins always see the project. + +```bash +spin project share --team --admin +spin project share --everyone --admin +``` + +Whoever sees a project opens workspaces from it, in a team of theirs. See +[spin project](/reference/cli/project/). + +## Opening from a URL + +You can [open a repository, a branch, a commit or a pull request from its URL](): +on the dashboard's `/launch` page, or from the terminal with [spin open](/reference/cli/open/). + +```bash +spin open https://github.com// +spin open https://github.com///tree/ +spin open https://github.com///commit/ +spin open https://github.com///pull/ +``` + +The project is found by its repository among the projects you see. When more than one has it, +`--project` says which. A repository no project has is one to make a project of first. + +A plain repository URL opens at the project's ref. A workspace can also be +[opened at a commit or at a pull request, which is checked out as `pr-`](). + +The workspace is made in `--team`, or in your personal team, and sized by the project's preset +unless `--preset` names another. `spin open` ends in `spin ssh`, +unless you pass `--no-connect`. + +To open a project by its slug instead of a URL: + +```bash +spin workspace create --project --ref +``` + +## Prebuilds + +A project can name refs to prebuild. Each one is built as it moves, and the result is a +snapshot. [A workspace that opens the project at that ref is cloned from its prebuild](), +so it starts from what the build already made. The builds run as the person who named the refs. + +```bash +spin project create --name --repository --ref --prebuild-ref +spin project prebuilds +spin project prebuild +``` + +`spin project prebuilds` lists each build of a ref at a commit and what became of it. +`spin project prebuild` builds a ref now, at the commit the forge says it is at. A commit whose +build failed is built again. Only the project's admins can run it. + +An installation can retire a ready prebuild that no workspace has opened for a while. The +project's next open then starts from the image. See [Snapshots](/concepts/snapshots/). + +## Your GitHub account + +A private repository on GitHub is cloned with the workspace owner's own account. You +[connect your GitHub account once](), +and it clones and pushes your workspaces' private repositories. The control plane keeps the +token. The host your workspace runs on sets it on the requests a network policy brokers to +GitHub, so no workspace's disk holds it. + +Connect in the dashboard, under Settings → Git, or +[from the CLI, with a code you enter on GitHub's page](): + +```bash +spin git connect +spin git list +``` + +Either way, GitHub asks you to authorize the installation's app. The token never reaches your +machine. `spin git connect` needs "Enable Device Flow" checked on the installation's GitHub App. + +To choose a repository for a project, +[list the repositories your account reaches](), +or only those whose name has a query in it: + +```bash +spin git repos +spin git repos +``` + +`spin git disconnect github` forgets the account and has GitHub revoke the token. See +[spin git](/reference/cli/git/). diff --git a/src/content/docs/concepts/runners.mdx b/src/content/docs/concepts/runners.mdx index 38e7302..e91b676 100644 --- a/src/content/docs/concepts/runners.mdx +++ b/src/content/docs/concepts/runners.mdx @@ -51,7 +51,7 @@ runners of that class. An installation the module's current release makes has on ## Scaling from zero -On AWS the control plane sizes the runners' group itself. It knows what nothing else does: that +On AWS [the control plane sizes the runners' group itself](). It knows what nothing else does: that a workspace is waiting, and when the last one stopped. - With no runner up, the first workspace to start waits in *Waiting for a host* while the control @@ -70,7 +70,7 @@ A runner that is going away empties itself first. It suspends each of its worksp memory, to the bucket, and each one resumes on another runner from where it was. This happens when: -- AWS reclaims a spot runner (about two minutes' notice), +- [AWS reclaims a spot runner]() (about two minutes' notice), - the autoscaling group removes a runner (the group holds it for `drain_seconds`), - a release is rolled out and runners are replaced. diff --git a/src/content/docs/concepts/snapshots.md b/src/content/docs/concepts/snapshots.md index 0d9a9a6..8801a55 100644 --- a/src/content/docs/concepts/snapshots.md +++ b/src/content/docs/concepts/snapshots.md @@ -82,7 +82,7 @@ This is how you run the same starting state many times, for an agent's attempts ## Sharing -A snapshot can be shared with another user, or made public: +[A snapshot can be shared with another user, or made public](): ```bash spin share add alice@example.com @@ -92,7 +92,7 @@ spin share browse # snapshots shared with you Sharing hands over the **whole disk**, including anything left on it, such as `/root/.ssh/authorized_keys` or a token in a dotfile. Before a snapshot is shared, Spinbox scans it -for credentials, and refuses the share until you acknowledge what it found +for credentials, and [refuses the share until you acknowledge what it found]() (`--acknowledge-findings`). **Memory is never shared**: a shared snapshot gives the disk alone. When someone creates a workspace from a snapshot you shared, the first boot removes your diff --git a/src/content/docs/concepts/workspaces.md b/src/content/docs/concepts/workspaces.md index c9aba83..afff88f 100644 --- a/src/content/docs/concepts/workspaces.md +++ b/src/content/docs/concepts/workspaces.md @@ -9,7 +9,7 @@ reads: A workspace is a virtual machine that belongs to a person, or to a team. It has its own kernel, root access, a persistent disk and SSH. It runs on a runner, but it is not tied to one: -its disk lives in the installation's bucket, and it can start again on any runner. +its disk lives in the installation's bucket, and [it can start again on any runner](). ## Creating one @@ -38,7 +38,7 @@ spin workspace create --name retry --from-snapshot --wait ## Resources -A workspace is sized by a **preset**: its cores, its memory and its free disk. An administrator +A workspace is [sized by a **preset**](): its cores, its memory and its free disk. An administrator defines the presets (for example `small` and `large`) and marks one as the default. See [Installation settings](/admin/configuration/). An installation whose configuration names no presets is given one, `default`: 1 core and 512 MB, which grow to what its runner has, on 10 GB @@ -46,8 +46,8 @@ of free disk. A preset is fixed, starting at its size and staying there, or it bursts: it starts at a floor and may grow up to its size, or up to whatever its runner has room for. A running workspace -can **grow**: the runner lends it more CPU and memory, up to that ceiling, and takes them back. -`spin workspace grant` gives it cores and memory of its own, kept until +can **grow**: [the runner lends it more CPU and memory, up to that ceiling, and takes them back](). +[`spin workspace grant` gives it cores and memory of its own](), kept until `spin workspace release` gives them back. The disk is the image's size plus the preset's free space. @@ -68,11 +68,15 @@ space. | `QUARANTINED` | Held by an administrator with `spin workspace quarantine`: it cannot start until `spin workspace unquarantine` releases it, and its disk is kept as it was. | | `DEGRADED` | Its machine is up, and what was asked of it inside did not finish: its project's checkout, .spin.yaml or create tasks. Everything a running workspace allows is allowed; `spin workspace get` says what failed, and the next boot tries again. | +An administrator [quarantines a workspace](): it cannot start until they release it. A runner tries a start three times before the workspace is `FAILED`. `spin workspace status` -says why a workspace is not behaving, and `spin workspace console` shows what its machine printed. +[says why a workspace is not behaving](), and `spin workspace console` shows what its machine printed. ## Stop, suspend, delete +A workspace is [created, started, stopped and deleted]() from the CLI, the dashboard and +the API. + - **Stop** shuts the machine down, uploads what the disk wrote since its last commit, and keeps the disk. The next start boots from it. Stopping does not take a snapshot. `spin workspace stop --force` stops the machine without waiting for the guest or the upload, @@ -107,7 +111,7 @@ person's identity from the disk: SSH keys, shell history and credentials. ## When a runner is lost If the runner a workspace was on disappears, the workspace keeps what reached the bucket and -loses what did not. Nothing moves it without asking: +loses what did not. [Nothing moves it without asking](): ```bash spin workspace recover api # says what moving it would lose diff --git a/src/content/docs/quickstart.md b/src/content/docs/quickstart.md index 1cc8dd7..2b14b3b 100644 --- a/src/content/docs/quickstart.md +++ b/src/content/docs/quickstart.md @@ -41,7 +41,7 @@ spin login --endpoint https://app. ``` The CLI opens the dashboard in your browser and shows a code. Approve the code there, and the -CLI is signed in. `spin whoami` shows who you are, and `spin smoke` checks the installation does +CLI is signed in. `spin whoami` shows who you are, and [`spin smoke` checks the installation does]() what this page expects, with a workspace of its own that it erases. ## 3. Create a workspace diff --git a/src/content/docs/use/agent-keys.md b/src/content/docs/use/agent-keys.md index 2b4e312..1641e3a 100644 --- a/src/content/docs/use/agent-keys.md +++ b/src/content/docs/use/agent-keys.md @@ -8,7 +8,7 @@ reads: A coding agent needs a key to talk to its provider, and a key on a workspace's disk is in every snapshot of it and in every clone. Spinbox has what it takes for the key to never be there: the -host's egress proxy can stand in for a host name and set a header from a secret the team holds. +host's egress proxy can [stand in for a host name and set a header from a secret the team holds](). Nothing here knows what Anthropic or OpenAI is; it is four things Spinbox already does, in order. ## 1. The installation brokers the name @@ -72,9 +72,9 @@ spin settings set env.OPENAI_API_KEY spin That is the whole recipe. The CLI talks to `api.anthropic.com` by its real name, the guest's trust store holds the installation's egress root, which its registration hands it, and the -request leaves the host with the team's key on it. HTTP/1.1, HTTP/2 and WebSockets all go -through: a WebSocket's handshake is a request like any other, and what follows its 101 is -carried as bytes. +request leaves the host with the team's key on it. [HTTP/1.1, HTTP/2 and WebSockets all go +through](): [a WebSocket's handshake is a request like any other, and what follows its 101 is +carried as bytes](). ## Your own services: an identity, not a key @@ -110,7 +110,7 @@ spin workspace egress agent-1 spin workspace status agent-1 ``` -`egress` lists each request the proxy read to a brokered name, newest first, with the real +`egress` [lists each request the proxy read to a brokered name](), newest first, with the real host's status or, for one the proxy refused, why: `credentials` is a secret the team does not hold, `scope` a method or path the rule does not allow. `status` says, per injection, whether the team holds what it is set from. diff --git a/src/content/docs/use/agent.md b/src/content/docs/use/agent.md new file mode 100644 index 0000000..fbf4986 --- /dev/null +++ b/src/content/docs/use/agent.md @@ -0,0 +1,97 @@ +--- +title: The agent in a workspace +description: Open T3 Code in a workspace, sign Claude Code and Codex in once, and see what the agent reaches, where its chats live and how it drives other workspaces. +reads: + - "spin:cmd/cli/cmd/agent.go@v20261108.01" + - "spin:cmd/cli/cmd/agent_home.go@v20261108.01" + - "spin:cmd/cli/cmd/workspace_threads.go@v20261108.01" + - "spin:cmd/cli/cmd/workload.go@v20261108.01" +--- + +A workspace can run an agent UI of its own. [T3 Code runs in the workspace as its agent +user](), +`spin-agent-t3`, and drives Claude Code and Codex from a browser: threads, checkpoints, and +switching between them. + +## Open it + +```bash +spin agent +spin agent --start +``` + +The server listens on the workspace's loopback alone. Your own SSH session forwards it to your +computer, and the command prints a link that pairs your browser with it. `--port` picks the +local port. Ctrl-C ends the forwarding, not the server. `path` defaults to the project's +checkout, or to `/workspace` in a workspace with no project. + +The workspace's Agent tab in the dashboard opens the same server. It frames the client and +carries it over your passkey's session. + +You do not start or stop the server yourself. [The supervisor starts it when you ask, and stops +it once nobody uses it and no chat is at +work](). +Every flag is in [`spin agent`](/reference/cli/agent/). + +## Sign in once + +[You sign Claude Code and Codex in from the agent's +page](). +Each is the CLI's own login, run by the supervisor as the agent's user. The credentials stay in +the agent's home and never pass through the page. + +[One login signs in every workspace of +yours](), +plain or a project's. Each workspace hands back every refresh and logout. A workspace that names +a guest holds none. + +This is a known gap: whoever runs code in one of your workspaces holds the provider token of +all of them, until you log out or revoke it at the provider. See [Known +gaps](/reference/security/known-gaps/). + +## What it reaches + +The agent's user has no capability and is given only `path`. [Its traffic is marked, and it +reaches only brokered +names](). +An administrator decides which names those are: see [An agent without keys](/use/agent-keys/). + +## Chats and the agent's home + +```bash +spin workspace threads +``` + +[The workspace lists the agent's +chats]() +as its supervisor last reported them: a title, the directory, the model and a time. Never a +message. You read a chat in T3 Code. + +[The agent's home is a volume of its +own](), +mounted at `/home/spin-agent-t3`. Erasing a workspace leaves its home yours, unmounted. A new +workspace mounts it when it is created and finds the chats there. + +```bash +spin agent home list +spin workspace create --agent-home --wait +spin agent home delete +``` + +`delete` erases a home no workspace mounts: its chats, its key and its bytes. + +## Driving other workspaces + +`spin` in a workspace needs no login of its own. [An agent there drives other workspaces on a +key it gets for the workspace's +identity](). +The key is a child of the key that made the workspace. It holds no more than that key, and goes +when the workspace goes to the trash. + +```bash +spin -o json workspace list +``` + +The call goes through the workspace's host, whatever the workspace's network allows: an agent +in an `offline` workspace drives others too. For keys, scopes and review, see [Agents and +automation](/use/agents/). diff --git a/src/content/docs/use/agents.mdx b/src/content/docs/use/agents.mdx index b08b639..d00cd71 100644 --- a/src/content/docs/use/agents.mdx +++ b/src/content/docs/use/agents.mdx @@ -43,13 +43,13 @@ Every scope, and what it allows, is in [`spin api-key`](/reference/cli/api-key/) A key can never approve a login or administer the installation. It lasts at most a year. The secret is shown once. -Two scopes are apart from the rest. `workspace:exec` runs commands and reads and writes files in -your workspaces through the API, as root: a key that holds it is made with your passkey, in the +Two scopes are apart from the rest. `workspace:exec` [runs commands and reads and writes files in +your workspaces through the API](), as root: a key that holds it is made with your passkey, in the dashboard, and every call it makes is in the audit log. `keys:delegate` lets a key make keys of its own, for the agents it starts: each holds no more than it does - its scopes, team, projects, end, and how many workspaces and cores all of them hold (`--max-workspaces`, `--max-cores`) - -and goes when it is revoked. [`spin mcp`](/reference/cli/mcp/) serves an agent the API on such -a key. +and goes when it is revoked. `spin mcp` [serves an agent the API on such a key](): +see [its reference](/reference/cli/mcp/). Give it to the agent through the environment: diff --git a/src/content/docs/use/editors.md b/src/content/docs/use/editors.md index d51b08f..90ea3d5 100644 --- a/src/content/docs/use/editors.md +++ b/src/content/docs/use/editors.md @@ -13,8 +13,8 @@ to the runner or to the workspace, only to `https://app.`. ## A key that logs in as you -Everything below - `spin ssh`, plain `ssh`, `spin exec`, VS Code - logs in with an SSH key you -added once, with your passkey: +Everything below - `spin ssh`, plain `ssh`, `spin exec`, VS Code - [logs in with an SSH key you +added once, with your passkey](): ```bash spin ssh-key add laptop ~/.ssh/id_ed25519.pub @@ -44,7 +44,7 @@ rsync -a ./src/ spin.api:/root/src/ ``` `spin ssh-config` writes `~/.spin/ssh/config` and includes it from `~/.ssh/config`. From then on, -any program that runs `ssh` reaches a workspace as the host `spin.`. Each connection +[any program that runs `ssh` reaches a workspace as the host `spin.`](). Each connection gets a new certificate for your key that is valid for five minutes, so there is no long-lived credential to leak. @@ -60,7 +60,7 @@ spin code api spin code api /srv/app --start ``` -This opens VS Code with Remote-SSH at the path you name: by default the project's checkout, +[This opens VS Code with Remote-SSH]() at the path you name: by default the project's checkout, `/workspace/`, or `/workspace` in a workspace with no project. It needs the `code` command and the Remote-SSH extension; `--editor` names another editor's command. @@ -97,11 +97,11 @@ spin exec api -w /srv/app -- git status spin exec api -- cat build.log > local.log ``` -`spin exec` runs a command in the workspace, connects its standard input, output and error to +[`spin exec` runs a command in the workspace](), connects its standard input, output and error to yours, and exits with its exit status. It allocates a terminal only when both ends are one, so its output is safe to pipe. ## Letting someone else in -`spin workspace guests add` lets a person into a workspace of yours, with a key of their own: +`spin workspace guests add` [lets a person into a workspace of yours, with a key of their own](): see [Sharing a workspace](/use/access/#sharing-a-workspace). diff --git a/src/content/docs/use/settings.md b/src/content/docs/use/settings.md new file mode 100644 index 0000000..9b2de35 --- /dev/null +++ b/src/content/docs/use/settings.md @@ -0,0 +1,74 @@ +--- +title: Your settings +description: What you set once with spin settings and every workspace of yours applies, and when a workspace is given none of it. +reads: + - "spin:cmd/cli/cmd/settings.go@v20261108.01" + - "spin:docs/access.md@v20261108.01" +--- + +Your settings are yours, not a workspace's. You save them once, and +[every workspace you own applies them as you saved them](). +A workspace's own `.spin.yaml` goes over them. + +## Seeing them + +```bash +spin settings get +``` + +## Shell, editor and environment + +```bash +spin settings set shell /bin/zsh +spin settings set editor vim +spin settings set env.GOPROXY direct +``` + +`shell` is your login shell. `editor` is your default editor. `env.KEY` sets the environment +variable `KEY`. + +## Dotfiles + +Name a repository, and the command that installs it. The repository is cloned into new workspaces. + +```bash +spin settings dotfiles --repo https://github.com//dotfiles +spin settings dotfiles --repo https://github.com//dotfiles --branch main --install ./install.sh +spin settings dotfiles --clear +``` + +Without `--branch` the branch is `main`. Without `--install` the command is `./install.sh`. + +## A Taskfile + +A startup Taskfile runs when your workspaces start. Check a file before you save it: +`--validate` reads it and saves nothing. + +```bash +spin settings taskfile --validate +spin settings taskfile --file +spin settings taskfile --clear +``` + +## A workspace's own environment + +A workspace can hold variables of its own, set when you create it or later. They go over your +environment, in that workspace only: another workspace's environment never reaches it. + +```bash +spin workspace create --name --env = +spin workspace update --env = +``` + +## When a workspace is not given them + +A workspace with a guest in it, or cloned from somebody else's snapshot, is given none of your +dotfiles, environment or Taskfile. Letting a guest into a workspace that already applied them +changes what it holds: +[What you set, and what a workspace applies](/use/access/#what-you-set-and-what-a-workspace-applies) +says what is taken out and what stays. + +## Reference + +[spin settings](/reference/cli/settings/) lists every command and flag. +[SettingsService](/reference/api/settingsservice/) is the same over the API. diff --git a/src/lib/nav.mjs b/src/lib/nav.mjs index 0c0f98d..f339986 100644 --- a/src/lib/nav.mjs +++ b/src/lib/nav.mjs @@ -29,6 +29,7 @@ export const nav = [ topic: 'docs', items: [ ['Workspaces', 'concepts/workspaces'], + ['Projects and repositories', 'concepts/projects'], ['Snapshots and rollback', 'concepts/snapshots'], ['Images', 'concepts/images'], ['Disks and storage', 'concepts/volumes'], @@ -43,8 +44,10 @@ export const nav = [ items: [ ['The CLI', 'use/cli'], ['Who gets in', 'use/access'], + ['Your settings', 'use/settings'], ['Editors and SSH', 'use/editors'], ['Agents and automation', 'use/agents'], + ['The agent in a workspace', 'use/agent'], ['An agent without keys', 'use/agent-keys'], ['The dashboard', 'use/dashboard'], ], @@ -71,6 +74,7 @@ export const nav = [ ['Users and teams', 'admin/users'], ['Operating hosts', 'admin/hosts'], ['Taking a runner out', 'admin/runners'], + ['Jobs, the bill and the store', 'admin/operations'], ['Observability', 'admin/observability'], ['Backups and recovery', 'admin/recovery'], ], diff --git a/src/lib/pages.ts b/src/lib/pages.ts index 3df02c6..06bc03e 100644 --- a/src/lib/pages.ts +++ b/src/lib/pages.ts @@ -126,7 +126,9 @@ export function toMarkdown(d: Doc, base: string): string { // MDX nests components with tabs; left in, Markdown reads the text inside them as code. body = body.replace(/^\t+/gm, ''); // A claim's link to the feature board is a link to the row's area on the status page. - body = body.replace(/\]\()]+)>?\)/g, (_, name: string) => `](${featureHref(name).href})`); + // The target is in angle brackets, so a row's name may hold a parenthesis; a < or > of the name is + // written percent-encoded, as in any link. + body = body.replace(/\]\(]+)>\)/g, (_, name: string) => `](${featureHref(decodeURIComponent(name)).href})`); body = links(body, base); body = body.replace(/\n{3,}/g, '\n\n'); untranslated(d, body); diff --git a/tools/refgen/commands.go b/tools/refgen/commands.go index c89213e..21e1d9d 100644 --- a/tools/refgen/commands.go +++ b/tools/refgen/commands.go @@ -275,7 +275,8 @@ func (c cliHelp) check(args []string) (string, error) { } continue } - if len(h.subs) == 0 { + // A placeholder is an argument a reader fills in, never a command's name. + if len(h.subs) == 0 || placeholder.MatchString(a) { continue } known := false diff --git a/tools/refgen/features.go b/tools/refgen/features.go index 7a3e0ad..fefc22a 100644 --- a/tools/refgen/features.go +++ b/tools/refgen/features.go @@ -3,6 +3,7 @@ package main import ( "bytes" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -11,8 +12,9 @@ import ( // featureLink is a page's claim that Spinbox does something, written as a link to the row of the // feature board that says so: [idle suspend](). -// The build turns it into a link to the row's area on the status page (src/lib/feature-links.mjs). -var featureLink = regexp.MustCompile(`\]\()]+)>?\)`) +// The target is in angle brackets, so a row's name may hold a parenthesis; a < or > of the name is +// written percent-encoded, as in any link (rowNamed reads it). The build turns it into a link to the row's area on the status page (src/lib/feature-links.mjs). +var featureLink = regexp.MustCompile(`\]\(]+)>\)`) // checkFeatureLinks fails a claim whose row the board does not have, and one whose row is not // built: a page may say Spinbox does what the board says it does, and nothing else. @@ -25,7 +27,7 @@ func checkFeatureLinks(docs string, rows []feature) ([]string, error) { err := handPages(docs, func(path string, raw []byte) error { for i, line := range bytes.Split(raw, []byte("\n")) { for _, m := range featureLink.FindAllSubmatch(line, -1) { - name := string(m[1]) + name := rowNamed(m[1]) switch s, ok := state[name]; { case !ok: bad = append(bad, fmt.Sprintf("%s:%d: no row of spin's feature board is %q", path, i+1, name)) @@ -54,7 +56,7 @@ func checkSaid(docs, src, list string, rows []feature) ([]string, error) { err := handPages(docs, func(path string, raw []byte) error { for _, re := range []*regexp.Regexp{featureLink, featureProp} { for _, m := range re.FindAllSubmatch(raw, -1) { - said[string(m[1])] = true + said[rowNamed(m[1])] = true } } return nil @@ -98,3 +100,13 @@ func checkSaid(docs, src, list string, rows []feature) ([]string, error) { } return bad, nil } + +// rowNamed is the row a link's target names, its percent-encoding undone. A target that is the +// encoding of nothing is taken as written, and fails as a row the board does not have. +func rowNamed(target []byte) string { + name, err := url.PathUnescape(string(target)) + if err != nil { + return string(target) + } + return name +} diff --git a/tools/refgen/figures.go b/tools/refgen/figures.go index 34aefcb..550377b 100644 --- a/tools/refgen/figures.go +++ b/tools/refgen/figures.go @@ -133,14 +133,19 @@ func proseFigures(raw []byte) []proseFigure { case fence: continue } - for _, m := range saidFigure.FindAll(codeSpan.ReplaceAll(line, nil), -1) { + // A link's target is a row's name or an address, which are not the page's words. + line = linkTarget.ReplaceAll(codeSpan.ReplaceAll(line, nil), []byte("]")) + for _, m := range saidFigure.FindAll(line, -1) { out = append(out, proseFigure{i + 1, string(m)}) } } return out } -var codeSpan = regexp.MustCompile("`[^`]*`") +var ( + codeSpan = regexp.MustCompile("`[^`]*`") + linkTarget = regexp.MustCompile(`\]\((?:<[^>]*>|[^)\s]*)\)`) +) // constantIs says how the constant name of spin's file differs from want - a count, or a // duration as time writes one - or "". diff --git a/tools/refgen/figures_test.go b/tools/refgen/figures_test.go index 2d4b52b..8a391c1 100644 --- a/tools/refgen/figures_test.go +++ b/tools/refgen/figures_test.go @@ -14,17 +14,18 @@ func TestCheckFigures(t *testing.T) { for name, c := range map[string]struct { page, list, source, want string }{ - "held by a duration": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", ttl, ""}, - "held by a count": {"Tried three times.", "p.md | three times | spin:p/p.go#Tries = 3\n", ttl, ""}, - "listed with why nothing holds it": {"About 220 MB.", "# sizes\np.md | 220 MB | not held: the size of Microsoft's server\n", ttl, ""}, - "a flag's own value is the CLI's": {"Pass `--expires 30 days`.\n\n```bash\nsleep 5 minutes\n```", "", ttl, ""}, - "a figure with no line": {"Lasts an hour.", "", ttl, `says "an hour", which nothing holds`}, - "the constant moved": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", strings.Replace(ttl, "15", "10", 1), "TTL is now 10m0s, not 15m"}, - "the count moved": {"Tried three times.", "p.md | three times | spin:p/p.go#Tries = 3\n", strings.Replace(ttl, "= 3", "= 5", 1), "Tries is now 5, not 3"}, - "the constant is gone": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#Life = 15m\n", ttl, "no longer declares Life"}, - "the file is gone": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:q/q.go#TTL = 15m\n", ttl, "spin no longer has q/q.go"}, - "a line the page no longer says": {"Lasts.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", ttl, `p.md does not say "fifteen minutes"`}, - "a line with no third place": {"Lasts.", "p.md | fifteen minutes\n", ttl, "a line is "}, + "held by a duration": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", ttl, ""}, + "held by a count": {"Tried three times.", "p.md | three times | spin:p/p.go#Tries = 3\n", ttl, ""}, + "listed with why nothing holds it": {"About 220 MB.", "# sizes\np.md | 220 MB | not held: the size of Microsoft's server\n", ttl, ""}, + "a link's target is not the page's": {"[It stops]() and [reads](/a/30-days/).", "", ttl, ""}, + "a flag's own value is the CLI's": {"Pass `--expires 30 days`.\n\n```bash\nsleep 5 minutes\n```", "", ttl, ""}, + "a figure with no line": {"Lasts an hour.", "", ttl, `says "an hour", which nothing holds`}, + "the constant moved": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", strings.Replace(ttl, "15", "10", 1), "TTL is now 10m0s, not 15m"}, + "the count moved": {"Tried three times.", "p.md | three times | spin:p/p.go#Tries = 3\n", strings.Replace(ttl, "= 3", "= 5", 1), "Tries is now 5, not 3"}, + "the constant is gone": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:p/p.go#Life = 15m\n", ttl, "no longer declares Life"}, + "the file is gone": {"Lasts fifteen minutes.", "p.md | fifteen minutes | spin:q/q.go#TTL = 15m\n", ttl, "spin no longer has q/q.go"}, + "a line the page no longer says": {"Lasts.", "p.md | fifteen minutes | spin:p/p.go#TTL = 15m\n", ttl, `p.md does not say "fifteen minutes"`}, + "a line with no third place": {"Lasts.", "p.md | fifteen minutes\n", ttl, "a line is "}, } { t.Run(name, func(t *testing.T) { docs, spin := t.TempDir(), t.TempDir() diff --git a/tools/refgen/reads_test.go b/tools/refgen/reads_test.go index 9d9db55..5b0ec6b 100644 --- a/tools/refgen/reads_test.go +++ b/tools/refgen/reads_test.go @@ -114,16 +114,18 @@ func TestPinReleasesLeavesAReadsStamp(t *testing.T) { // Every built row is said by a page or owed in the list, and the list owes nothing else. func TestCheckSaid(t *testing.T) { - rows := []feature{{Name: "Said", State: "built"}, {Name: "Owed", State: "built"}, {Name: "A limit", State: "limit"}} + rows := []feature{{Name: "Said", State: "built"}, {Name: "Owed", State: "built"}, {Name: "A limit", State: "limit"}, {Name: "Open (by its URL)", State: "built"}} for name, c := range map[string]struct { page, list, want string }{ - "said and owed": {"[it]()", "# why\nOwed\n", ""}, - "said by a card": {``, "Owed\n", ""}, - "a built row nobody says or owes": {"[it]()", "", `built "Owed" and no page says it`}, - "an owed row a page says now": {"[it]() [and]()", "Owed\n", `"Owed" is said by a page now`}, - "an owed row the board has not": {"[it]()", "Owed\nGone\n", `"Gone" is no built row`}, - "an owed row that is not built": {"[it]()", "Owed\nA limit\n", `"A limit" is no built row`}, + "said and owed": {"[it]()", "# why\nOwed\nOpen (by its URL)\n", ""}, + "a name written percent-encoded": {"[it]() [and]()", "Owed\n", ""}, + "a name with a parenthesis": {"[it]() [and]()", "Owed\n", ""}, + "said by a card": {``, "Owed\nOpen (by its URL)\n", ""}, + "a built row nobody says or owes": {"[it]()", "Open (by its URL)\n", `built "Owed" and no page says it`}, + "an owed row a page says now": {"[it]() [and]()", "Owed\nOpen (by its URL)\n", `"Owed" is said by a page now`}, + "an owed row the board has not": {"[it]()", "Owed\nOpen (by its URL)\nGone\n", `"Gone" is no built row`}, + "an owed row that is not built": {"[it]()", "Owed\nOpen (by its URL)\nA limit\n", `"A limit" is no built row`}, } { t.Run(name, func(t *testing.T) { docs, src := t.TempDir(), t.TempDir() diff --git a/tools/refgen/unsaid.txt b/tools/refgen/unsaid.txt index cbe004a..07c7f1b 100644 --- a/tools/refgen/unsaid.txt +++ b/tools/refgen/unsaid.txt @@ -3,77 +3,14 @@ # leaves by being linked from the page that says it - [words]() - and # task reference:check fails on a line whose row is said, gone or no longer built, and on a built # row that is neither said nor here. -Create, start, stop and delete A process inside holds its workspace awake while it lives -Cores and memory lent while the host has them, and taken back -A grant of cores and memory of its own, for a while -An administrator changes a running workspace's bandwidth, its connections open Clone a workspace: its disk, team, project, size, policies and environment, in one -Open a repository, branch, commit or pull request from its URL, in the dashboard (/launch) and from the terminal -A workspace opened at a commit or a pull request, checked out as pr- -Recover a workspace whose host is gone, at a cost its owner accepts -A workspace outlives its runner -Quarantine, by an administrator -Why a workspace is not behaving: status, console, history -The owner's shell, environment, editor settings, Taskfile and dotfiles reach the guest as they save them -Share a snapshot with a person or make it public, and create a workspace from it -What looks like a credential is found before a share, and the share refused unless acknowledged A long chain is compacted while the workspace runs -A workspace's DNS goes upstream only for what its policy names, so a policy that closes egress closes the tunnel through names -A policy changed reaches the host that runs the workspace -Brokered names: the host's proxy stands in and adds what the workspace never holds - a team secret, a forge token, its identity, a cloud role -What a workspace reached, what it looked up and what it was refused -Each request to a brokered name as the proxy read it, served or refused and why; and whether each injection's secret is the team's to give The egress proxy is a process of its own, holding no key of the host's, started again by the runner when it ends -A WebSocket to a brokered name: its handshake under the rule's headers and scope, then bytes each way -HTTP/2 to a brokered name, the proxy's ALPN the real host's: a rule's headers set stream by stream, gRPC's trailers both ways Watch where a workspace connects, into its logs -An agent the supervisor starts is marked, and reaches only brokered names -Published ports at https://{port}-{workspace}.{domain}: private, whitelisted or public -Sign in with GitHub or an OIDC provider -Device login for a terminal -SSH keys, and plain ssh to spin. -A key logs in only once its person adds it with a passkey: their own for good, a browser's for twelve hours -Guests of a workspace, let in by its owner with a passkey A workspace's identity, signed by its host, for the audiences its owner allows -VS Code over SSH, with the server the installation publishes and nothing downloaded -An agent UI: T3 Code in a workspace as its agent user, over the owner's SSH session - from `spin agent` on the person's computer, or in the workspace's Agent tab in the dashboard, which frames the client and carries it over a passkey's session -Claude Code and Codex signed in from the agent's page: each CLI's own login, run by the supervisor as the agent's user, its credentials kept in the agent's home and never passing through the page -A person signs Claude Code and Codex in once: every workspace of theirs, plain or a project's, is handed the same credentials and hands back each refresh and logout, and a workspace that names a guest holds none -The supervisor runs the agent for its owner while it is used: started, paired and listed over the owner's own session on a socket only root opens, and stopped once nobody has used it for half an hour and no chat is at work -The agent's chats listed on the workspace - titles, paths and times, never a message - reported by its supervisor -The agent's home is a volume of its own: the chats and sessions outlive the workspace, kept in the installation's store as every disk is, and a new workspace mounts them at its creation -Run a command in a workspace, copy files to and from it -A program runs a command and reads and writes files in a workspace through the API, with a key that holds workspace:exec, every call recorded with the key it came on -An agent works in workspaces through `spin mcp` on a key delegated to it: never more than the key it came from - scopes, team, projects, end, how many workspaces and cores its tree holds - and revoked with it -An agent in a workspace drives others with spin, on a key it gets for the workspace's own SPIFFE identity - a child of the key that made the workspace, through its host, whatever the workspace's network -Teams, with owner, admin and member -A team's own identity providers -Your GitHub account, connected once: private repositories cloned and pushed with nothing on the disk -Your GitHub account connected from the CLI, with a code entered on GitHub's page -A repository picked from the ones your account reaches -A team's own network policies, where the installation allows them -Projects of the installation: a repository and the shape of the workspace that opens it, shared with everyone or some -Prebuilds of a project's refs, as the floor of the workspaces that open them A team's workspaces run on the installation's hosts -Installation settings seeded from a file; a change in the file is a proposal to apply or dismiss -Resource presets Registration: open, by allowlist, or closed -An audit log -The control plane's jobs, and running one now -The month's bill, its forecast, and what each workspace cost -The store: every volume, whose it is, and taking out what nothing needs -Re-key a workspace's disk -Runners: registration tokens, drain, activate, revoke, and a policy per host -Runners start when a workspace waits for one and stop when nothing runs -A root shell on a runner, for a while and with a reason: the runner emptied first, every session recorded under Object Lock, the runner retired after A host that reboots brings its workspaces back -A cloud's reclaim warning empties the host before the machine goes -A writeback bound per host, measured on that host before it is taken -What is running where: the control plane, each runner and its machine -Platform logs, workspace logs and traces, searched in the product -Metrics, with Grafana on the control plane -A workspace's journal and console are collected as the installation's policy says A workspace's last hour of CPU, memory and network against what it holds, for its owner -A smoke test of what an installation's API promises, with a workspace of its own A host serves only the releases it carries