Subsystem

Part 6 of 6 Running one for other people

An agent's own credential

By the end of this chapter, an agent has an account of its own on the team's instance, reaches the website rebuild and nothing else, and what it writes arrives under its own name rather than under the name of whoever set it up.

Install it, and tell your agent did this on Laurence's own laptop, where the agent runs on the machine that holds the work and one command finishes the job. Here the agent runs somewhere else. You issue the credential on the server and hand it over, and the difference shows up immediately, in the flag you leave off.

The work so far

Laurence has the instance of An account, an install and a first run, reached at the address TLS, a proxy and systemd gave it, and a project for the work:

$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin subroutine
$ sudo python3 -m venv /opt/subroutine
$ sudo /opt/subroutine/bin/pip install "subroutine[postgres]"
…
Successfully installed …
$ sudo install -d -o subroutine -g subroutine -m 0755 /var/lib/subroutine
$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine init --workspace metacortex --instance-name MetaCortex
Ready. Try: subroutine add "something to do"
$ sudo -u subroutine tee -a /var/lib/subroutine/config/subroutine/config.toml > /dev/null <<'SETTING'
public_url = "https://tasks.example.com"
SETTING
$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine project create web "Website rebuild"
Created web … Website rebuild
  Tip: subroutine add "something to do +web"
$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine serve
Serving on http://127.0.0.1:8471
Reached at https://tasks.example.com
…

One command, and the flag to leave off

$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine agent create claude --profile worker --project web | tee /tmp/for-the-agent
Created service account claude, with the contributor role.

  sr_…

That is the only time the credential is shown. Nothing recovers it afterwards.

Checked, by presenting it: claude (agent), in metacortex (comment:read, comment:write, project:read, task:read, task:write, workspace:read), and only within web

Nothing here will use it yet … '--store' is what records it on this machine.
Until then its shell acts as subroutine, and nothing above bounds what it does there.

subroutine agent create makes the account, puts it in the workspace with a role, and issues its credential, in one act. That matters more than it looks: an account with no membership authenticates perfectly and can do nothing, which reads as a broken token rather than as a missing role, and over a network the alternative is three requests with a half-finished agent if the second fails.

--store is the flag to leave off here, and it is the one Install it, and tell your agent uses. It records the credential as this machine's agent, and this machine is the server. The agent does not run here. Recording it here would do nothing for the agent and would quietly narrow the commands you run on this server.

So the last two lines are true and expected rather than a problem: nothing on this server will ever present this credential.

The line beginning Checked is not an echo of what you typed. It is what the instance answered when the command presented the credential it had just minted. A scope naming a permission the role does not carry, or a pin on a workspace the account cannot reach, turns up on that line rather than on the agent's first call, a week later, as an error somebody else has to interpret.

What a profile says

--profile names a situation instead of assembling one out of flags, and it expands into exactly those flags: there is nothing a profile can express that you could not have typed.

Profile Reaches Writes in For
worker one project, and everything filed under it the same an agent that owns a project
collaborator the projects named the ones named with --write an agent that reads related work for context and writes only its own
observer the projects named, or the whole workspace nothing a reporting or reviewing agent
colleague one workspace the same a second person, working in it as they would in their own

A combination that means two things at once is turned down rather than resolved:

$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine agent create nosy --profile observer --write web
'--write' does not go with the 'observer' profile.
  write: 'observer' changes nothing at all.
    Either drop '--write', or use '--profile collaborator' for an agent that reads widely and writes in one place.

A credential that quietly does something other than what you just described is one nobody checks again.

Verbs and items are separate questions

--scope decides which verbs a credential carries. --project decides which items it can reach at all. They are different axes and it is worth naming them separately, because most mistakes here are somebody reaching for one and meaning the other.

subroutine token create is the same work without an agent behind it, which is what a scheduled job wants:

$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine token create --service-account reporter --scope task:read --title "weekly digest" | tee /tmp/for-the-reporter
Created service account reporter, with the contributor role.

sr_…

That is the only time it is shown. Store it now.
Give it to a client as SUBROUTINE_TOKEN, or add it to /var/lib/subroutine/config/subroutine/credentials.toml.

And the other axis:

$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine token create --service-account webbot --workspace metacortex --project web
Created service account webbot, with the contributor role.
Restricted to web and anything filed underneath.

sr_…

That is the only time it is shown. Store it now.
Give it to a client as SUBROUTINE_TOKEN, or add it to /var/lib/subroutine/config/subroutine/credentials.toml.

It brings the sub-projects with it, which is why the command says so rather than repeating what you typed: a restriction that stopped at one level would be useless on any tree deeper than one. Everything outside it is not merely missing from a listing. The project does not resolve, so the credential is told there is no such project rather than that it may not look.

Name a project by its key, which is unique within a workspace rather than across the instance. Where two workspaces both hold a web, the command asks which rather than choosing, because an agent pointed at the wrong tree works perfectly against the wrong tree.

What the bound is, over the wire

The credentials are in the two files those commands were teed into. These lines take them out, exactly as a reader would paste them:

$ agent=$(grep -o 'sr_[A-Za-z0-9_-]*' /tmp/for-the-agent | head -1)
$ reader=$(grep -o 'sr_[A-Za-z0-9_-]*' /tmp/for-the-reporter | head -1)

The read-only credential reads:

$ curl -so /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $reader" https://tasks.example.com/v1/tasks
200

and does not write:

$ curl -s -H "Authorization: Bearer $reader" --json '{"title":"nope"}' https://tasks.example.com/v1/tasks | grep -o '"detail":"[^"]*"'
"detail":"This needs the 'task:write' (tasks and documents) permission. Your role allows it, but the token you used is scoped to a narrower set."

Read that refusal carefully, because it distinguishes two things most do not. The role would have allowed the write; the token is narrower. An agent that reads this knows it has been deliberately bounded rather than misconfigured, which is the difference between getting on with something else and filing a bug against you.

The agent's own credential does write, in its own project:

$ curl -so /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $agent" --json '{"title":"Fix the deploy script","project":"web"}' https://tasks.example.com/v1/tasks
201

It cannot widen itself

The obvious worry about handing a credential to something that writes its own commands is that it issues itself a better one. It cannot:

$ curl -s -H "Authorization: Bearer $agent" --json '{"service_account":"wider","scopes":["task:read","task:write"]}' https://tasks.example.com/v1/tokens | grep -o '"detail":"[^"]*"'
"detail":"This affects the whole installation, and needs the 'instance:user_create' permission. Only an administrator of this instance holds it."

Issuing a credential affects the whole installation rather than one workspace, so it needs a permission the contributor role these accounts get does not carry. That holds however the credential is scoped, and it is worth knowing before you hand one out rather than after.

Whose name it arrives under

$ sudo -u subroutine env \
    XDG_CONFIG_HOME=/var/lib/subroutine/config \
    XDG_DATA_HOME=/var/lib/subroutine/data \
    XDG_STATE_HOME=/var/lib/subroutine/state \
    /opt/subroutine/bin/subroutine journal
…
    …  @claude (agent, @subroutine)  created               #1 Fix the deploy script

An agent, and whose. That is the whole reason for giving it an account rather than lending it yours: the record says a machine did this, and says which account answers for it, and neither fact can be recovered afterwards from a change that carries somebody's name.

The account it answers to is whoever ran agent create, which on this server is the service account rather than a person. There is no flag for it, and nothing here can supply one: the command records the account it was run as. Where it matters that an agent answers to the person whose work it does, that person issues the credential themselves rather than asking you to issue it for them.

Handing it over

The credential goes to the client as SUBROUTINE_TOKEN. It is never accepted in a query string and never read from config.toml, so there is nowhere it can end up by accident.

The address goes with it, and where the instance holds more than one workspace it carries ?workspace=, as An agent, with nothing installed uses it. One workspace and there is nothing to say.

At the other end there are two places to put it, and which one depends on how much of that machine the agent should be:

Not checked: the build does not run this.

{
  "env": {
    "SUBROUTINE_TOKEN_WORK": "sr_…"
  }
}

That goes in .claude/settings.local.json in the project's directory. The name after SUBROUTINE_TOKEN_ is the connection's, upper-cased, with anything that is not a letter or a digit as an underscore, so a server added as work gives SUBROUTINE_TOKEN_WORK. Claude Code hands every entry in that block to everything it starts in that project, which is both of the ways an agent reaches an instance at once. Tell them to check the repository ignores that file before a credential goes into it.

The half you cannot do from here

A credential is resolved per process, not per agent. An agent reaches an instance two ways at once: through the tools its editor wired up, and by running subroutine in a shell. Those are separate processes and each finds its credential for itself, so configuring the tools does nothing about the shell, which finds whatever the command line finds, normally the person's own.

The result of getting one half right is an agent that is itself half the time and the person the other half. That is worse than plainly acting as the person, because it is partial: anybody spot-checking finds the agent's name on the half that went through its tools and concludes the setup worked.

Credentials are looked for in this order, and the first one found wins:

  1. SUBROUTINE_TOKEN_<CONNECTION> in the environment, the connection's name upper-cased
  2. SUBROUTINE_TOKEN, for the default connection only
  3. whatever the connection's own token_env or token_command names
  4. credentials.toml, which holds the agent's token where one is stored and the process is an agent's, and the person's otherwise

Only the last step can tell the two apart, and it does so by the variable the editor sets on everything it starts. So the thing to ask for, from whoever runs that machine, is the pair of checks rather than either one: subroutine whoami in the agent's shell, subroutine_whoami through its tools, and the same command in their own terminal. Install it, and tell your agent runs all three.

With that, the credential is issued, bounded and proved from this end, and what it writes arrives under its own name. Everything else about it is somebody else's machine, which is exactly the boundary this chapter is drawn along.