Writing

What we found when we put agents on a cloud sandbox

The plan said the machine running the agents would keep its network shut and the terminals would dial out to us. A probe pool answered differently on both counts, and the architecture moved to match. Every claim below came back from a real request.

A sealed machine with one channel through its wall, carrying frames both ways, and a second attempt refused for want of a token.controlsandboxwith a token403without one
One channel through the wall, carrying frames both ways, and nothing at all without a token.

Everything hung on the WebSocket

Agents live in terminals, and a terminal is a stream. If the platform’s entry point had only allowed request and response, the whole shape would have needed rethinking. It allows the upgrade, and it moves frames in both directions.

GET /?identifier=<id>&api-version=2024-02-02-preview
Authorization: Bearer <token>

101 Switching Protocols
  frames server to client, and client to server

With no token the same request is 403 Unauthenticated. That is the finding that matters most: the machine that executes model-written code has no public surface of its own, and the platform’s identity layer holds the door rather than a secret we invented and have to rotate.

The trap is the audience. This is Azure Container Apps dynamic sessions, and the token has to be issued for the dynamic sessions audience. A management audience token, which is the one every example and every CLI hands you first, is refused with a 403 that looks exactly like the unauthenticated case. That is worth an hour of somebody’s afternoon, so it is written down here.

One identifier is one container

Two different identifiers on the same pool give two different containers, with different hostnames inside them. That is the primitive the product needed: one canvas, one machine. It also means the canvas identifier can be the routing key directly, so there is no registry of tunnels to keep, nothing to reconcile after a restart, and no state that can disagree with reality.

Egress cannot be switched off

The earlier version of the plan named the platform’s no-egress mode as the native hardening, and that sentence read like a fact. It is unusable. With egress off the machine reaches neither our own control half, nor the code host, nor the model API, and an agent that cannot reach the model is not an agent. With egress on, the code host answered 200, the model API answered 401, which is the answer that proves it is reachable, and our own site answered 200. All three are needed for an agent to do anything.

So the isolation cannot come from cutting the network. It has to come from what the machine holds, which is why the database connection and the decryption key were taken off it instead. The one thing the platform had already closed: the instance metadata address is unreachable from inside a session, so there is nothing to block there.

The platform decides more of the architecture than the design does.

Three details that will bite

The proxy announces a ceiling of thirty minutes per request, in a header on the way in. Whether it applies to a WebSocket is not something a document will tell you, and the only way to find out is to hold one open for longer than that. Either way the control half has to be able to reconnect a live link without the browser noticing, which is a thing worth building before you need it.

The identifier is not in the environment. A session gets an instance value and nothing else, and the identifier appears only in a forwarded path header. So the machine cannot know which canvas it is serving when it boots. It learns that from the opening frame of the link, which turned out to be the shape the protocol wanted anyway.

The lifecycle is not only a timer. There is an exit-driven mode as well, which matters because putting a canvas to sleep is a decision the watcher makes about whether anything is happening, not a decision a wall clock makes.

What the measurement changed

The plan had the machine dialling out to us, chosen on the assumption that dialling in was impossible. It is not impossible, and now both directions stand up, with a real trade between them. Dialling out needs a registry of tunnels and a shared secret we would have to invent. Dialling in needs neither, because the canvas identifier routes and the platform authenticates, but it couples our control half to one vendor’s API.

The decision was to not decide, in the protocol. The relay envelope wraps a connection, not a direction. Dialling in is wired first because it is the shortest path and it is proven. Dialling out stays available the day portability matters more than convenience, and it costs nothing today to keep it available.

If you are building the same thing

Measure the platform before you draw the diagram. The first version of that section was written from documentation and reasoning, and it read like a set of facts. The probe overturned part of it, including the piece the entire hardening story had been resting on.

Write down the guess that cost you an hour. The audience mismatch produced a 403 identical to the one you get with no credential at all, which is the kind of detail that never makes it into a release note and always makes it into somebody’s afternoon.

Keep the transport out of the protocol. The direction a connection is opened in turned out to be a deployment detail, and the only reason that was cheap is that nothing in the message format had an opinion about it.