The deployed workspace runs the same code as the local one. A workspace names an adapter set, and that is the only thing that changes: the encrypted credential file becomes Secret Manager and the local directory becomes a bucket. Everything above them, meaning connections, providers, policy, and limits, is declared once and applies to both.
$ lanes link start --profile personal --workspace cloud # local: a directory, an encrypted file
$ lanes link deploy --profile personal --workspace cloud # deployed: one bucket, Secret Manager, Cloud RunTwo standing dependencies, and that is the whole list. No database: state is one object per key and the audit log is one object per event, both in the same bucket as memory, tasks, assets, skills, attachments, and the config itself (ADR-020, ADR-021, ADR-023).
If you find yourself needing an application-layer change to make the second one work, that is a bug in the adapter boundary rather than a step in this guide.
What you need
- A Google Cloud billing account.
- The Google Cloud CLI, authenticated:
gcloud auth login && gcloud auth application-default login.
That is the list, and note what is not on it: a project. There is no second vendor to sign up
with, no database to provision, and no key pair to mint in a console. The gcs adapter
authenticates as the service account the deploy creates, so the bucket needs no credential of its
own.
The project, its billing link, the APIs, the Artifact Registry repository, the bucket, the runtime
service account and its IAM bindings are all created by lanes link deploy on its first run. Every
one of them follows from what the workspace declares or from one answer at a prompt, so transcribing
them into a console was work with no decision in it. --dry-run prints the whole sequence before any
of it runs.
A project of its own is the default because it is the cleanest boundary available: this one holds
the bucket, the credential store with your live refresh tokens, and nothing else. Deploying into a
project you already use for other things works, and you type its name at the prompt, but it means a
secretAccessor binding in a project whose other workloads you now have to think about.
The config
Two files, one workspace. The connections are the workspace's; the profile selects from them.
You do not write this. lanes link deploy asks for the handful of things it
cannot derive: project, billing account, region, service name, bucket, and who may reach it.
and writes the whole block, proposing a default for every one. It is shown here because it is
worth being able to read, and because you may want to edit it later; it is not a transcription
exercise. Every adapter below has exactly one workable answer on Cloud Run, and one of the wrong
ones (storage: filesystem) appears to work.
# connections.yaml, in the bucket
contract: 3
connections:
- id: main
provider: gmail
account: you@example.com# profiles/personal.yaml, in the bucket
contract: 3
instance:
profile: personal
port: 7337
auth:
mode: bearer
token_ref: profile/token
# How a remote client, a Claude or ChatGPT connector, including on a phone,
# gets a token. It signs its owner in at lanes.sh and comes back with one; the
# bearer token above is for CI, which has no browser. See "Who can reach it".
authorization:
mode: self
grants:
- connection: gmail.main
allow: ['gmail.*']
# Who may consume it. A deployed workspace bound to a Lanes workspace may name
# anyone that workspace holds, which is how somebody else reaches this endpoint
# without being given a credential.
members:
- { subject: lanes:3QBmAxJLLrYSMTVUIeCN1SKFbdD3, role: owner }The deploy loop
$ lanes link deploy --profile personal --workspace cloud # everything, from nothing
$ lanes link connect gmail --profile personal --workspace cloud # a browser consent per account
$ lanes link outputs --profile personal --workspace cloud # the URL an agent needsconnect publishes the config to the bucket the revision reads and asks the revision to re-read
it, so it takes effect without a second deploy. Deploy again when the code changes.
More than one deployment
cloud is a workspace name rather than a keyword. A second deployment is named on the deploy that
creates it, and everything downstream takes the same flag:
$ lanes link deploy --profile personal --workspace staging # surveys, writes workspaces.staging, rolls a revision
$ lanes link workspace list --profile personal # what this profile declares, and which is in play
$ lanes link secrets push --profile personal --from cloud --to staging
$ lanes link outputs --profile personal --workspace stagingThe revision carries its own name: the rollout sets LANES_LINK_TARGET=<workspace> on the service, so
a staging container opens staging's adapters. Give each its own project and bucket unless you
intend them to share a credential store; the survey proposes fresh names, so pressing return through
it is the safe answer.
deploy always names its --workspace (ADR-037). It used to infer one, the workspace declaring a
deploy block, inventing cloud when there were none, and that inference was a defence against
instance.default_target, which is local and by definition not deployed. With the fallback gone
the defence has nothing to defend against, and what was left was three behaviours from one command
line on the command that creates cloud resources and rolls a public URL.
What it does not name is a profile. A deploy sends every profile declaring the workspace, in one
revision, because that is the set the endpoint will open (ADR-009, ADR-041). --profile narrows it.
A first deploy is the exception: a workspace nothing declares yet has no set to derive, so name the
profile it belongs to.
deploy is the only command that may name a workspace which does not exist yet, since creating it is
what a first deploy is for. That is also why it does not read LANES_LINK_TARGET, where a typo
would be surveyed and rolled out rather than refused.
lanes link deploy runs check, asks for anything the config does not say yet and writes the
answers into your profile, creates the project-level resources on a first run, gets the credential
store to a state a revision can boot from, uploads the workspace, builds the image through Cloud
Build, rolls a revision, and prints the URL. It is a wrapper, not a deployment engine: there is no
state file, no lease, no drift reconciliation, and no rollback manifest, because Cloud Run revisions
already are the rollback.
The order of those matters and used to be wrong. Provisioning ran after the credential check and the workspace upload, which is fine on every deploy after the first and impossible on the first: the credential check asked Secret Manager for a token in a project where that API was not enabled yet, and the upload wrote into a bucket that did not exist. Both failed several steps before the step that would have created what they wanted.
connect comes second, not first. It authorises against a real account and writes into the
workspace's credential store, which on a first run does not exist until deploy has enabled Secret
Manager and created the bucket. The second deploy is not a formality either: a revision decides
which connections are usable during its boot reconcile, so one that came up before an account was
authorised goes on refusing it until a new revision replaces it. deploy prints both the exact
connect commands and this reminder when it finishes.
lanes link deploy --dry-run prints every gcloud invocation without running any of them, and reads
and writes no credential. Use it the first time, and any time you want to run a step yourself.
What it asks
Every run, not just the first. With no cloud workspace at all it asks for the whole thing:
project, billing account, region, service name, bucket, who may reach it, and whether a remote
client has to, and writes workspaces.<workspace>, plus auth.authorization when the last answer is
yes. With a workspace already declared it asks only about the deployment: project, region, service,
access.
Every prompt defaults to what the config already says, so pressing return through the survey changes nothing and re-generates nothing. By the second run the random project and bucket names are stored values, not fresh draws. What it buys is that the four settings deciding where a revision lands are in front of you each time rather than in a file you have to remember to open, and changing one costs a line instead of an edit.
The adapters are not re-asked once declared. The bucket holds the config, the state and the log, so renaming it does not move a deployment, it abandons one: a deliberate edit rather than a prompt to press return through.
--access overrides for a single run without writing anything.
Slow lookups say so. Reading your gcloud configuration and checking whether a project exists both
shell out and can take seconds, and a prompt that is about to appear looks exactly like one
waiting for input. Press return into that silence and the terminal buffers the keystroke for the
question you never saw. Each of those waits now prints what it is doing and takes the line back.
A run with nobody at the keyboard skips the survey and uses what the config holds, so a scripted
deploy needs no flag. --non-interactive says the same thing explicitly, for a terminal attached to
a job nobody is watching; it assumes the "create these now?" confirm too. Neither can rescue a workspace
whose answers are missing. That still refuses, at the prompt it could not ask.
The names it proposes. A project id and a bucket name are both unique across every Google Cloud
customer, so five random letters are drawn once and both take the same name:
lanes-link-<random>. One string, two namespaces, and finding either from the other needs nothing
written down. They are written into your profile, so the second deploy reads them back; a fresh
suffix per run would name a fresh empty project beside the one holding everything.
The service name carries the profile instead: lanes-link-<profile>-mcp. That is the name that has
to differ when one project serves two profiles, and the one you read in the Cloud Run console
months later.
The default project used to be whatever gcloud config was pointed at, a value that is always
set, rarely the right one, and wrong in the expensive direction: accepting it puts a credential
store holding live refresh tokens into whichever project you last worked on.
The billing account is asked only when the project does not exist yet, since that is the only time it changes anything, and it is refused rather than defaulted when the login has no open account, because a project without billing enables no API, and every step after it then fails describing the API rather than the billing.
Getting the credentials in
A deployed instance never mints its own token, and writes exactly one thing: the vault document,
if you use the vault. Everything else flows one way, from your CLI. deploy mints the endpoint
bearer token into the workspace's store if there is none, and asks for nothing else. A value already
in the store is left alone.
If you would rather do it up front, or copy a setup you already built locally:
$ lanes link token rotate --profile personal --workspace cloud # mints the profile bearer token
$ lanes link secrets push --profile personal --from local --to cloud # or copy a setup you built locallysecrets push copies; it never deletes from the source, and it skips a reference the destination
already holds unless you pass --overwrite. That default matters: a token rotated against the cloud
workspace is newer than your local copy, and overwriting it silently would break the deployed instance
with no error anywhere.
lanes link secrets set reads the value from stdin, not from an argument. An argument is in your shell
history, in ps output while the command runs, and in any transcript of the session.
What the service account needs
Four bindings, each narrower than it looks:
| Grant | Scope | Why not wider |
|---|---|---|
roles/secretmanager.secretAccessor | one binding per secret it reads | Read at boot and while serving: OAuth refresh tokens, the endpoint's own bearer token, the vault key. |
roles/secretmanager.secretVersionAdder | one binding per secret it rotates | The vault document, and each connection's OAuth token. Add a version, never create. See below. |
roles/storage.objectAdmin | conditioned on data/, less each profile's providers.d/ | What the endpoint owns and writes: state, the log, attachments, memory, and skills (writable under policy, ADR-014). Manifests are carved back out, because they are config, and ADR-007 says a revision never rewrites its own. |
roles/storage.objectViewer | the bucket itself, plus profiles/, lanes-link.yaml, and each providers.d/ | Reading its own config. Deliberately not admin, as below. The bucket is named as well because listing is granted there and nowhere else. |
deploy creates the account and all of them on a first run; --dry-run shows them, and
--service-account names a different one.
And it takes away the ones it replaced. gcloud ... add-iam-policy-binding adds: a binding is
keyed on role, member and condition together, so changing a condition's expression writes a second
binding beside the first, and IAM evaluates the set as a permissive union, so the widest expression
wins. Three deploys in a row narrowed reads-its-config while the revision went on holding
objectViewer on every object in the bucket, under a title claiming the opposite, because the two
attempts before them had been refused by CEL and every step here tolerates failure.
So each deploy reads the policy it is about to change and removes what it superseded: a binding
under one of these condition titles whose expression is no longer the one being applied, an
unconditioned binding on a role that is only ever granted conditionally, and the project-wide
secretAccessor that per-secret reads replaced. Additions run first and removals after, always,
the two are one edit to a live policy, and the other order opens a window in which the revision
currently serving holds no grant at all.
Nothing is recorded between deploys to make that work. There is no state file, no lease and no drift
reconciliation (init.md rules all three out, and a record would only ever agree with
itself); the policy is read, because IAM is the thing that actually decides. A policy that cannot be
read, whether that is no gcloud, a bucket that does not exist yet, or a login without the permission, plans no
removals at all rather than guessing.
Why the write grant is conditioned. ADR-007 says a deployed instance never mutates its own
configuration. That used to be enforced by the config being baked into a read-only image, which
stopped being true when the workspace moved into the bucket (ADR-023). The condition is where that
guarantee went: the revision may write what it owns and may only read what declares what it is. A
blanket objectAdmin would silently undo it, which is why driver.test.ts asserts the shape.
Why the read grant also names the bucket. storage.objects.list is checked against the
bucket, never against an object. A prefixed listing is one call to the bucket carrying a filter,
not a walk of matching resources, so no condition written in terms of objects/… can grant it, and
Google says as much: IAM conditions cannot restrict object listing by prefix. The condition
therefore admits projects/_/buckets/<bucket> as well as the three config paths.
The only permission that can follow from it is storage.objects.list. Everything else in
objectViewer is evaluated against an object, where the prefixes still decide, or is project-level
and out of a bucket binding's reach. So the concession is the names of what is in the bucket;
reading any of it stays where ADR-007 puts it.
This was invisible for as long as the read binding sat at expression=true, which matches the
bucket as readily as an object. The first deploy that actually removed that binding rolled a
revision that could not list its own workspace, and grants.test.ts had not caught it because it
evaluates the conditions against object keys, and a listing has no object in it. It does now.
Why the Secret Manager write grant is per secret. The line is not read versus write, since the revision plainly writes. It is rotating what exists versus bringing something into existence. Two things it does are writes:
- Refreshing an OAuth token. Access tokens last about an hour and the refreshed one is persisted,
so an ordinary "check my mail" rewrites
gmail/<connection>a few times a day. This is not a background job you can grant separately; it happens inside the request. vault put, which is a write an agent may legitimately make under policy (ADR-022).
So deploy binds roles/secretmanager.secretVersionAdder on each of those secrets by name, and
creates each container itself so the revision never needs secretmanager.secrets.create, and a
project-level permission that would let it mint credential references of its own, and destroy
versions. A binding on one secret is already scoped to it, so none of these needs a condition to be
narrow. See ADR-026.
The list is scoped exactly as the upload is: a deploy naming no --profile sends every profile up,
so it binds every profile's connections. A connection authorised after a deploy has no binding,
and no config in the bucket either, so the revision cannot reach it at all until the
lanes link deploy that connect already tells you to run.
Credential references become secret ids
Secret Manager ids allow [A-Za-z0-9_-], and a credential reference contains /. The adapter
encodes the separator as __, so gmail/main is stored as gmail__main and vault/document as
vault__document. That encoding lives entirely inside the adapter, so every command still speaks
in references, but it is what you will see in the console, and it is why a reference whose own
segments contain __ is refused rather than silently sharing a secret with another one.
Secrets in the project that do not decode to a valid reference are ignored, so sharing a project with other workloads is fine.
Storage is not optional up here
adapter: filesystem on Cloud Run appears to work. Every write succeeds, every read within the
life of an instance succeeds, and the bytes are gone when the instance recycles, which it does on
idle, on deploy, and whenever the platform feels like it. Nothing errors, because from the
container's point of view nothing is wrong.
That was worth stating when the bucket held only memory and skills. It is worth more now: the bucket also holds the config the endpoint reads, its connection state, its audit log, every task, and every file kept as an asset. A deployment on the filesystem adapter is one that forgets what it did, and for assets that is the only copy, since the point of keeping one is that the endpoint can reach it from anywhere.
That mix is also why the deploy creates the bucket with Autoclass, terminal class ARCHIVE. The
same bucket holds the config read on every boot and an attachment nobody opens twice, so no single
storage class is right for it and a lifecycle rule would be a guess written by hand. Autoclass moves
each object on its own access pattern: untouched for thirty days it cools to Nearline, and it keeps
sinking to Archive from there. Inside such a bucket there are no retrieval fees and no
early-deletion fees, so a read pulls the object back to Standard at no charge. That is what makes
the colder floor safe rather than a bet on never needing the file again. Objects under 128 KiB never
leave Standard at all, so the config, the state and the log rows are untouched by this; the saving
is on assets and attachments.
It applies to a bucket the deploy creates. A bucket from an earlier deploy is left exactly as it is
(the create step finds it present and moves on) so turning Autoclass on for an existing one is a
change you make yourself, in the console or with gcloud storage buckets update.
What protects the bucket, on every deploy rather than only the first
Three things, and they are applied by a buckets update step precisely because of the paragraph
above: a flag on the create reaches a bucket made after the change and no other, and every
deployment that already exists is the one holding an audit log worth keeping.
- Public access prevention, enforced. Nothing here is served to a browser, so the useful setting is the one that makes granting anonymous read impossible rather than merely absent. Uniform bucket-level access already removed per-object ACLs; this removes the bucket-level route.
- Soft delete, thirty days. The revision holds
objectAdminon everything underdata/, andobjectAdmincontainsstorage.objects.delete. That grant is right, because the endpoint writes state, memory, tasks, assets and the log, and rewriting an object is deleting the old one, but it means the process most exposed to the internet is also the one that can erase the record of what it did. Thirty rather than the platform's seven, because the gap this closes is noticing late. - Object versioning, with a lifecycle rule that bounds it (
src/deployments/gcp/lifecycle.json: noncurrent versions go at thirty days or ten newer copies, whichever comes first). This covers what soft delete does not: an object overwritten in place, where the previous content is the thing worth keeping, and state is the one thing here that is rewritten rather than appended.
None of this makes a deletion detectable. audit.tamper-evident in
security.md is explicit that deleting a run whole is not, and that has not changed.
It makes it undoable.
The vault key, which the deploy now mints
The vault document is sealed before it reaches Secret Manager, under LANES_LINK_VAULT_KEY, a
different key from LANES_LINK_CREDENTIAL_KEY, deliberately, because one master secret reused
across purposes turns any single compromise into a total one.
lanes link deploy mints it, the same way and for the same reason it mints the endpoint token: it
is a random string the process generates correctly and nobody can usefully choose. It is stored at
the reference vault/key in the workspace's own credential store, the secret vault__key, and
mounted on the revision as --set-secrets LANES_LINK_VAULT_KEY=vault__key:latest, so the value
never appears in argv, in a revision's description, or in anything describe returns.
This used to be three manual commands, and their only failure mode was forgetting them: the
revision booted healthy and every vault.* call failed at the first read.
Beside the document rather than somewhere else, which reads wrong and is not: ADR-022 put the ciphertext in Secret Manager because the key already came from there. What separates them is that the key is mounted as an environment variable and the document is not, so an attacker holding the document alone holds ciphertext.
It is never regenerated. A second key does not fail loudly; it decrypts nothing, and what it cannot open is every password the owner put there.
lanes link vault key generate still exists, for a local run or a key you would rather carry
yourself.
Cold starts
With minimum instances at zero, the first agent call after an idle period pays for:
- Container start, pulling and starting the image, then Bun booting. Bun runs TypeScript directly, so there is no bundle to load, and the image is small.
- Config read, a couple of object reads from the bucket. There is no database to connect to and no schema to migrate, so this step is two HTTPS requests rather than a TLS handshake plus a lock.
- Reconcile, one plan per profile against the config just read.
Expect a few seconds. An agent call that normally takes a second takes several after idle, once, and then not again until the instance is reaped.
Your options are exactly two, and both are legitimate:
- Accept it. For a personal gateway this is the right default.
--min-instances=0costs nothing while idle. - Set
--min-instances=1. The first call is fast every time, and you pay for an always-warm instance.
$ gcloud run services update lanes-link-personal-mcp \
--min-instances=1 --region europe-west1 --project my-projectLeave CPU allocation at "CPU only during requests". The server is stateless and does no background work between requests, so allocating CPU always would pay for idle time and buy nothing.
Who can reach it, and what scaling changes
There are two doors, and they are not alternatives. They are layers, and only the inner one can admit an agent.
The platform door is deploy.access. iam deploys with --no-allow-unauthenticated, so Cloud
Run checks the caller before the request ever reaches this code. What it checks is a Google-signed
identity token for this service, held by a principal with roles/run.invoker. No agent harness can
mint one, not Claude, not ChatGPT, not a claude mcp add registration, so iam is the right
choice for a service reached by other cloud workloads and the wrong one for a service reached by an
MCP client, which will see nothing but 403s.
The application door is src/auth, which does not know or care which workspace it is running on.
It accepts two kinds of credential:
- The profile bearer token, resolved from the credential store, the same mechanism as locally.
This is what
claude mcp add --headerand every local registration carry. - A token obtained through
auth.authorization, for a remote client that has nowhere to paste a fixed string.
access: iam is the default, because a workspace that says nothing about who may reach it should get
the closed answer. A workspace you intend to reach from an agent declares access: public.
--access iam|public overrides the declared value for one run; --iam is still accepted and means
--access iam.
There is no way to serve without authentication. --no-auth existed, printed that it had disabled
authentication, and did not: the flag reached a bind-address guard and never reached an
authenticator, so every request was still refused. It has been removed rather than repaired: the
loopback endpoint it would have opened has no Origin check standing behind it, so any page the
owner visited could have read their accounts.
/health answers {"status": "ok"} without a token, so the platform's probe and deploy can wait
on it. It names the profiles it serves only to a caller holding one, because that list is an inventory of
what this instance holds, and a deployed URL is readable by anyone.
Connecting a phone
mode: self means this endpoint issues the tokens, and there is nothing to set up: no OAuth client,
no console, no redirect URI. Deploy, then add a custom connector by URL in Claude or ChatGPT. The
client registers itself, a browser opens on this endpoint's approval page, and you paste the
endpoint token once, the string lanes link outputs --show --workspace cloud prints. That is the
whole flow, and it works the same on a laptop and on a phone.
Name the workspace. Credentials are per-workspace, and a bare lanes link outputs --show resolves to
instance.default_target, which is local on a scaffolded profile, whose token this endpoint has never
seen and will refuse. Worse, when that store is empty the command mints a fresh local token rather
than reporting that it has none, so what you paste looks like an answer and fails as a wrong
password. The approval page prints the workspace it is actually running as, so the command it shows
you is the one to run.
What makes it work is a handshake worth knowing about when it does not: /mcp answers 401 with a
WWW-Authenticate header pointing at /.well-known/oauth-protected-resource, which names this
origin as the authorization server, whose own document lives at
/.well-known/oauth-authorization-server. All three are readable without a token, deliberately,
they are how a client learns it needs one. If a connector reports the server as unreachable, curl
those three in order; the first one that does not answer is the problem.
$ curl -i -X POST https://…run.app/mcp | head -3 # 401, with resource_metadata
$ curl -s https://…run.app/.well-known/oauth-protected-resource | jq
$ curl -s https://…run.app/.well-known/oauth-authorization-server | jqCalling it from a browser
Nothing to configure. A deployment answers a cross-origin request from any page, because there is
nothing for an allowlist to defend: the endpoint is already reachable by anyone, the credential is an
Authorization header a page must already hold rather than a cookie a browser attaches on its own,
and Access-Control-Allow-Credentials is never sent.
To narrow it anyway, as an enterprise deployment might, name the origins:
auth:
mode: bearer
token_ref: profile/token
allowed_origins:
- https://app.exampleAn origin exactly: scheme, host, and port, with no trailing slash and no path. A browser sends
Origin: https://app.example, and a configured https://app.example/ compares unequal and would
refuse the origin you believed you had allowed, so the config refuses it up front rather than at
request time. The discovery documents are never narrowed by this; a client that cannot read them
cannot find out that it needs a token.
Two things it does not do. It grants no capability: what a caller may do once it holds a credential is
decided by policy, per call, exactly as for every other client. And it does nothing at all for
lanes link start. A loopback endpoint refuses every cross-origin request and must keep doing so,
because a page you happen to be visiting can otherwise reach 127.0.0.1, including the consent form
that asks you for your token. The field is read and discarded there. See
ADR-039.
Using an identity provider you already run
mode: oidc points the same handshake at somebody else's authorization server and reduces this
endpoint to verifying what comes back, against the audience, the expiry, and an allowlist of
subjects.
auth:
authorization:
mode: oidc
issuer: https://accounts.google.com
client_id_ref: oidc/client_id
# Only when the issuer publishes no `introspection_endpoint` of its own.
introspection_endpoint: https://oauth2.googleapis.com/tokeninfo
allowed_subjects: [you@example.com]The cost is setup, and it is worth being clear about before you choose it. The issuer needs an OAuth
client registered for this endpoint, with https://claude.ai/api/mcp/auth_callback among its
redirect URIs, and the client id stored at client_id_ref. Google in particular supports neither
dynamic client registration nor client-ID metadata documents, so its client id and secret have to be
pasted into the connector's advanced settings by hand, and ChatGPT, which needs dynamic
registration, likely cannot use that combination at all. mode: self is the default for exactly
these reasons. See ADR-018.
The allowed_subjects list is not optional and may not be empty. An issuer will vouch for every
account it has; which of them is you is not something it knows.
Rate limits are per instance. limits.requests_per_minute is enforced by an in-memory counter, so
a service running N instances enforces N times the configured limit in aggregate. A shared counter
store would be needed for a global limit, and that is not in scope. What bounds the aggregate instead
is max_instances, below. This used to say "cap --max-instances accordingly" and nothing in
deploy ever sent the flag, so the aggregate had no ceiling at all.
The ceilings a revision runs under
Five settings the rollout sends on every deploy, defaults included, for the reason min_instances
is also always sent: config decides, and a flag passed only when it differs from a default lets a
value be raised and never lowered. Absent, each fell to the platform's own default, a hundred
instances, eighty concurrent requests each, and 512 MiB to stage a 64 MiB upload in.
workspaces:
cloud:
deploy:
platform: cloudrun
# ...
min_instances: 0
max_instances: 4 # instances
concurrency: 40 # requests per instance
timeout_seconds: 300
memory: 1Gi
cpu: "1"You do not write these either. The survey carries them through, and pressing return changes nothing. They are here because they are worth being able to find.
max_instances is the one that matters on a public workspace. That is a routable address anyone can
send a request to, and every instance that starts reads the credential store and lists the bucket,
so scaling out multiplies cost and traffic against the two things this endpoint most wants kept
quiet. Four is a single-user endpoint's ceiling; a fifth concurrent instance is an agent in a loop.
Raise it if you are genuinely serving that much.
memory is a gigabyte because 512 MiB is not enough for what the endpoint already accepts: an
attachment is capped at 64 MiB and staging one costs roughly twice that at peak, so the platform
default is one upload away from an out-of-memory kill, which is a 503 for every other request that
instance was serving.
What answers before the token does
Four things answer without a credential, and on a public workspace that means to anybody:
/health, /register, /authorize, /token. Each costs a read of the credential store or an
object written to the bucket, so each is metered: two buckets, one per caller and one for the
endpoint, because the per-caller key is a forwarded address a stranger can rewrite. A /health
carrying no credential is free and stays free: it reads nothing, and it is what the platform's
probe and lanes link outputs send.
None of this applies to lanes link start. A loopback endpoint's credential store is a local file
belonging to whoever is already at the machine, and the cross-origin refusal covers the one caller
who is not. See ADR-054.
The image
src/deployments/gcp/Dockerfile, built from the repository root through
src/deployments/gcp/cloudbuild.yaml. Three things about it are worth knowing.
It is pinned by digest. FROM oven/bun:<version>@sha256:<digest>, with the tag kept beside it so
the next bump is legible. A tag is a pointer its publisher can move, so it is not a promise about
bytes: a rebuild months from now can pull a base image nobody reviewed, into a container holding live
refresh tokens. To bump it, change the tag and resolve it with
docker buildx imagetools inspect oven/bun:<tag> --format '{{.Manifest.Digest}}'.
The config is not in it. The image carries no lanes-link.yaml and no profiles/; deploy
uploads them to the bucket and passes LANES_LINK_HOME=gs://<bucket> at rollout, so one image
serves any workspace (ADR-023). It used to be baked in, and the image being read-only was what
enforced "a deployed instance never mutates its own configuration". That guarantee now lives in
the conditioned objectAdmin binding, which is why the condition is worth keeping narrow.
What that costs is rollback. A revision no longer fully describes what it serves, so rolling back
to an earlier revision does not roll back a config change made since; the bucket holds one current
copy. The upload is an allowlist: lanes-link.yaml, profiles/<profile>.yaml, and the two
authored directories inside the profile, data/<profile>/skills.d/ and
data/<profile>/providers.d/, so the rest of data/ cannot travel by accident, for the same
reason .dockerignore excludes it.
Everything else under data/ is excluded, and that exclusion is load bearing. The local
encrypted credential store and its key live there. A credential baked into an image is pushed to a
registry, cached on every builder that touched it, and readable by anyone who can pull the tag. The
deployed workspace reads credentials from Secret Manager and wants nothing from that directory.
It takes two files, because there are two things to stay out of. The root .dockerignore keeps
them out of the image. .gcloudignore keeps them out of the tarball gcloud builds submit uploads
to a Cloud Build staging bucket, which is packed and sent before any Dockerfile is read, so
.dockerignore has nothing to say about it. Without the second file gcloud derives its exclusions
from .gitignore when the context happens to be a git checkout and from nothing at all when it is
not, and deploy sends the installed package, which for the documented install method is a
directory under ~/.bun with no .git in it. The safe behaviour was being inherited from a
coincidence. Keep the first block of the two files in step; dockerfile.test.ts checks that you did.
The entrypoint is src/server/container.ts, not lanes link start. It logs plain lines to stdout for
Cloud Logging, handles SIGTERM, listens on $PORT, and, importantly, refuses to start when the
profile token is missing rather than minting one. A token invented inside a container that scales to
zero is a token nobody can read back, and the endpoint would come up healthy while rejecting every
agent.
To run the image locally against the cloud workspace's adapters:
$ docker build -f src/deployments/gcp/Dockerfile -t lanes-link .
$ docker run --rm -p 8080:8080 \
-e LANES_LINK_TARGET=cloud \
-e GOOGLE_APPLICATION_CREDENTIALS=/adc.json \
-v ~/.config/gcloud/application_default_credentials.json:/adc.json:ro \
lanes-linkTroubleshooting
No profile token at "profile/token". The container refuses to invent one. Run
lanes link token rotate --workspace cloud. A running revision re-reads within five seconds, so
neither a redeploy nor a fresh instance is needed; it used to be, because the value was cached for
the life of the process.
PERMISSION_DENIED: Permission "secretmanager.versions.access" denied. The revision's service
account is missing roles/secretmanager.secretAccessor. The adapter passes Google's message through
verbatim, and it names the permission.
PERMISSION_DENIED: Permission "secretmanager.versions.add" denied, on a read. Reading mail
refreshes an OAuth token and persists it, so the credential's secret needs a
secretVersionAdder binding and this one has none. Almost always a connection authorised since the
last deploy. lanes link deploy binds it; the message says so and names the ref.
PERMISSION_DENIED: Permission "secretmanager.secrets.create" denied. Do not reach for
roles/secretmanager.admin here. It reads as "the secret does not exist yet" and usually does not
mean that: Google checks IAM before existence, so this is also what a create against a secret that
is right there answers. Nothing in a running revision should be creating a secret at all, so a 403
here on the serve path means something asked for a permission it does not need, which was a real
bug, fixed in ADR-026. On the CLI side it means your own account cannot create secrets in that
project.
PERMISSION_DENIED: The caller does not have permission during a step, on a project you own.
enabling an API returns before the API is usable, and inside that gap Google answers calls to it
with a permission error rather than a "still starting" one. deploy enables seven APIs and then
uses all of them, so it is the most likely thing here to land in that window. It now waits: a step
that fails this way is retried with a backoff, against a budget of about two and a half minutes
shared across the whole run, and says so on each retry. If it still fails afterwards the message is
Google's and the problem is real.
GCS refused to write "…" (403). The revision's service account is not granted
roles/storage.objectAdmin on the bucket, or the deploy's IAM step was skipped. The message names
the role. Note the grant is conditioned: the revision may write under data/ and may only read
the config paths, so a 403 on profiles/…, or on a providers.d/… key, which sits inside data/
and is excluded from the write grant by name, is the guarantee working rather than a
misconfiguration.
The endpoint answers 401 for a token you just printed. claude mcp add stores the substituted
value, not the command, so a rotated token needs re-registration. lanes link outputs prints the command.
lanes link outputs shows a local URL for a cloud workspace. It asks Cloud Run for the service URL and
falls back to the configured host and port when gcloud is absent or the service is not deployed yet.
A brokered Google connection on Cloud Run
Nothing extra to bind. A connection authorised against the OAuth client Lanes operates rewrites exactly one secret while serving, its own token blob, which is already in the rotation grant, and it needs no client id or secret anywhere in the workspace's store.
The one requirement is outbound HTTPS to the broker host, which Cloud Run has by default. It
only becomes a question if you have set VPC egress to route all traffic: the revision refreshes
through api.lanes.sh, so that host has to be reachable or every Google call fails an hour after
the revision reports healthy.
A profile that registered its own client is the other way round: its oauth_apps refs are bound
readable by deploy so the refresh path can sign with them, and never writable.
ADR-026's line is that a revision rotates what
is its own and never rewrites the operator's client.