ADR 0037: The session lives in the operating system credential store
Amended 2026-10-01: the error for a refused pair from the environment says
when the refresh token expired or that it was refused before its expiry, which
.env holds the pair when the working directory's does, the saved session's
user and validity, and the command that repairs it. A renewal that fails with
any other answer than 400, 401 or 403 is reported as a backend failure.
See decision 4.
Amended 2026-09-30: on macOS the CLI asks for the secret of a Keychain entry only when the entry carries the CLI's own mark. The first implementation read any entry under the name, and an entry that another program had written made every process show a consent dialog. A session saved by another version is no longer read; one login replaces it. See macOS.
Amended 2026-09-30: mainsequence code-repository refresh-token is removed. Once
no checkout holds a credential, a token command that takes a checkout has no
purpose. mainsequence refresh-token renews the session for the machine.
Decision 2, the refresh command and the consequences below are edited
accordingly.
Date: 2026-09-30
Status: Accepted. Implemented for macOS and Linux. Windows keeps the behaviour it had; see Windows.
Context
Released versions, up to 8.1.25, kept a developer's credentials in two places.
mainsequence code-repository set-up-locallyandmainsequence code-repository refresh-tokenwrote them into the.envof each CodeRepository checkout: the access token and the refresh token, or, in runtime-credential mode, the credential id, its secret and an exchanged access token.mainsequence loginsaved the session for the CLI. On macOS it used the login Keychain through Apple'ssecurityprogram, with the record on the command line and, since 4.0.1, through a login shell. On Linux and Windows, and on macOS when the Keychain entry could not be read back, it used a plain file,auth.json, in the CLI configuration directory.
The copies in .env had three costs.
- The tokens were in plain text in every checkout.
- Each checkout held its own copy. A copy went stale on its own, so the developer refreshed each CodeRepository separately.
- One machine had several places that claimed to hold the session.
The SDK did not need the copy. On import it reads only the endpoint from the
checkout .env, and takes the tokens from the process environment or from the
saved session. The copy served tools that load .env into a process
environment.
An earlier change on this unreleased line moved the saved session to the
keyring library on all three systems and removed the plain file as a
fallback. Keeping the session only in the credential store requires the store
to answer every interpreter on the machine the same way, because each
CodeRepository has its own virtual environment. These are the observations of
2026-09-30, with keyring 25.7.0:
- macOS 26.4, Python 3.12.8 and 3.13.11. The Keychain grants access to an
entry per program. An entry written by the library, through the Security
framework, made the other interpreter wait on a consent dialog, and
/usr/bin/securitydid not read it without consent either. The library therefore tied the saved session to one interpreter, which the releasedsecuritypath had not done. An entry created throughsecuritywas read and replaced throughsecurityfrom both interpreters with no dialog, in less than 0.1 seconds per call.securitydeleted an entry that another program owned with no dialog. - Linux, Secret Service provided by GNOME Keyring. The library replaces its
own item in place: the item kept its identifier across writes. It knows its
own item by an
applicationattribute. When another program had stored an item for the same service and account without that attribute, a write by the library added a second item. security -ireads one command per line into a 4,096-character buffer. A longer line was cut there: the first part ran with a truncated secret and the rest ran as another command.
Decision
- The session is one record per backend in the operating system credential store. A login made from any CodeRepository serves every other one on the machine. There is no plaintext fallback: without a credential store the session lasts for the current process only.
- A CodeRepository
.envholds the backend endpoint and no credential.set-up-locallywrites the endpoint only, in every authentication mode. No command takes a checkout to refresh its tokens:code-repository refresh-tokenis removed, andmainsequence refresh-tokenrenews the one session of the machine. See The refresh command. - A local tool that does not read the credential store obtains a short-lived
access token with
mainsequence auth token. The refresh token never leaves the store through that command.mainsequence auth statusreports the session without any token value. - Credentials already in the process environment still win over the saved
session, and a rejected pair from the environment is not replaced by the
saved session, because that pair may belong to another user or backend. The
error says when the refresh token expired, or that it was refused before its
expiry; where the pair came from, naming the working directory's
.envwhen it holds the pair; the saved session's user and validity, or that there is none; and the repair:mainsequence refresh-tokenin that directory, or starting the process without the two variables, and a sign-in first when no saved session is usable. No token value appears in it.
The record
The record is ASCII JSON:
{"v": 1, "backend": "https://backend.example", "username": "...", "access": "...", "refresh": "..."}
backendis the normalized backend URL. A reader refuses a record that names another backend. A record without the field was written by a released version and is accepted.- A record with a refresh token and no access token is a complete session: the access token is renewed from it. A record with an access token only is a session in runtime-credential mode and nowhere else.
json.dumpsescapes non-ASCII characters, so every store holds the same bytes.- Released versions read
username,accessandrefreshand ignore the other fields.
The entry's service name is MainSequenceCLI.auth on every system. Its account
is default. followed by the first 16 hexadecimal digits of the SHA-256 of the
normalized backend URL. The account default, which versions before 4.0.1
used for every backend, is still read and is removed by a logout.
| System | Store | How the CLI reaches it |
|---|---|---|
| macOS | Login Keychain | /usr/bin/security, with the record on standard input |
| Linux | Secret Service | The keyring library's Secret Service backend, named explicitly |
| Windows | Credential Manager | The keyring library's recommended backend |
On every system, a session that a released version left in auth.json is moved
into the store and removed from the file once the store holds it. Without a
store the file is left untouched and is not used. A store that could not be
read is not an empty store: nothing is moved then, so the entry that could not
be read is not replaced by an older session.
macOS
macOS returns to security, which every released version used, under the same
entry name. security is one program for every interpreter, so an entry it
wrote is read from every CodeRepository without a dialog.
An entry that another program wrote is different: asking security for its
secret shows the consent dialog, in every process that starts. Such an entry
exists where the unreleased keyring build of this line saved a session, and
its attributes are the same as those of an entry security wrote, so the two
cannot be told apart. The CLI therefore marks every entry it writes with the
comment attribute MainSequenceCLI.session.v1, and asks for the secret only
after it has seen that mark. Attributes are read without consent, whoever
wrote the entry. An entry without the mark is never asked for: the CLI reports
that a session of another version is present and not read, and one login
replaces it. That holds for a session a released version saved as well, so an
upgrade on macOS needs one login.
A released version installed in another CodeRepository still shares the session. It reads the entry this code wrote, and its update keeps the mark.
Three things differ from the released path.
- The record travels on standard input, as one
add-generic-passwordline with the secret in hexadecimal. It used to be an argument of a command line, which other processes on the machine can list. securityis started directly, not through a login shell.- Every call has a 10-second limit.
A write updates in place an entry that the same process has read, so another
process never finds the session missing while a token is renewed. Any other
entry is removed and then added: removing an entry that another program owns
needs no consent, and the new entry belongs to security. A record whose line
would exceed 4,000 characters is refused before anything is removed or written,
so a long record cannot be stored as a truncated one. A session record of 800
characters makes a line of about 1,700; the limit leaves room for a record of
about 1,950.
A read can still wait for the user when the Keychain is locked. The call is cut
off at the limit and reported as a store error, and the process continues
without a saved session. An entry that could not be read, or that carries no
mark, is not looked at again for 60 seconds in that process. mainsequence
doctor, mainsequence auth status, mainsequence auth token and
mainsequence refresh-token say why the store could not be read, which
otherwise looks like a machine that is not logged in.
The cost of this arrangement is that any program running as the same user can
read the entry through security without a dialog. That was already so in the
released versions.
Linux
The Secret Service backend is named explicitly instead of taking the library's highest-priority backend, so the record is in the same store on every desktop. A write first removes the items that other programs stored for the same service and account, and then lets the library replace its own item in place. A delete removes every match.
The library's highest-priority backend differs from Secret Service in one case:
a KDE desktop with dbus-python installed in the same environment, where it is
the KWallet backend. A session saved that way is not read, and one login
replaces it. A desktop with no Secret Service provider has no saved session:
the CLI reports that persistence is unavailable and the session lasts for the
process.
Windows
Nothing about how the store is reached changes on Windows, and this decision
was not verified there. The record gains the v and backend fields; the
entry name, the backend and the persistence setting are the ones the keyring
library chooses.
The token command
mainsequence auth token prints an access token for the session the process
would use: credentials set in the environment, otherwise the saved session. It
renews the token first when it would expire within 60 seconds. It asks nothing
and opens no browser.
With the global --json flag the output is one object with endpoint,
access_token, token_type (Bearer) and expires_at (epoch seconds, or
null when the token carries no expiry). Without it the access token alone is
printed.
| Exit code | Meaning |
|---|---|
0 |
A token was printed |
1 |
There is no session, the store could not be read, or the backend did not renew the session |
3 |
The machine has no credential store and the environment carries no credentials |
mainsequence auth status exits 0 when a usable session exists and 1 when
it does not. Without --check it judges the session by the tokens' own expiry
and uses no network. Its store_error field is null, or the reason the
credential store could not be read.
The refresh command
mainsequence refresh-token renews the saved session: it obtains a new access
token from the refresh token, or from the runtime credential of a platform
runtime, saves it, and reports the backend, the user and the session's expiry.
It takes no path and no CodeRepository. It prints no token value, asks nothing
and opens no browser. With --json it prints the report of
mainsequence auth status plus removed_env_entries. Its exit codes are those
of the token command.
Tokens that an earlier version or another tool left in a checkout would be
used, by any tool that loads that .env, instead of the saved session. When
the directory the command runs in has a .env, the command therefore removes
MAINSEQUENCE_ACCESS_TOKEN, MAINSEQUENCE_REFRESH_TOKEN,
MAINSEQUENCE_RUNTIME_CREDENTIAL_ID, MAINSEQUENCE_RUNTIME_CREDENTIAL_SECRET
and the unsupported MAINSEQUENCE_TOKEN from it, together with a
MAINSEQUENCE_AUTH_MODE=runtime_credential line. It names the removed entries
and never their values, changes no other line, and creates no file. It does
this before it renews the session, so a stale entry goes even on a machine
that is not logged in. An entry counts in the forms the tools that load a
.env accept: leading whitespace, an export prefix, and whitespace before
the equals sign.
Consequences
- A tool that read the token pair from
.envno longer finds it there. It receives the pair from the environment it is started in, for example aftereval "$(mainsequence login --export)", or it asksmainsequence auth token. - A
.envwritten by an earlier version keeps its tokens untilmainsequence refresh-tokenruns in that checkout.mainsequence doctornames the credential entries it finds in the current checkout's.env. - The CLI still passes the session to the child processes it starts through their environment. That is unchanged.
Validation
Unit tests cover the per-system store selection, the record rules, the macOS
adapter against a stand-in for security, the Secret Service adapter against a
store that keeps the items of other programs, the removal of credential
entries from a .env, the refresh command, both auth commands and the
environment-source error.
The store functions were also run against real stores with test entry names and dummy values:
- Secret Service in a container. Two saves in a row kept the item's identifier. With an item from another program present, a save left one item. The library alone left two.
- The login Keychain on macOS 26.4 from Python 3.12.8 and 3.13.11, together
with the store code of the released 8.1.25. An entry the
keyringlibrary had written, and an entry the released code had written, were not asked for: both interpreters reported no session and the reason. A save replaced such an entry and both then read it. The released code read a session the new code saved and saved over it; the mark stayed and the new code read that session. A save after a read updated the entry in place. A too-long record was refused with the earlier session intact. No step waited for consent: each step, a whole process, took less than 0.4 seconds.