netherlands · cet

arnhem, nl — remote only

status: available

~/ insights

Cosyra

clientPortable Software, Corp. (US)
roleLead Flutter Developer
liveiOS · Android
stackFlutter · Kotlin · Go · Azure Blob Storage · Postgres · AKS

Cosyra is a mobile cloud terminal. You open an app on your phone and land in a full Ubuntu shell running in your own container, with Claude Code, Codex CLI, OpenCode and Gemini CLI already installed. The shell doesn’t run on the device — the app is a client, the work happens in a Kubernetes pod, and that is what makes a real terminal on a phone possible at all.

I was the lead Flutter developer on the terminal client. The piece I want to describe here is different, though: the terminal session archive — record a session, store it, play it back. I designed and shipped it end to end, across the Go PTY agent, the Go backend, the Postgres schema and the Flutter viewer.

It sounds like a small feature. It was the most interesting security problem on the product.

Cosyra marketing homepage for an AI coding terminal that runs on a phone
Cosyra, published by Portable Software. The shell runs server-side; the app is the client.

What "record a terminal" actually means

A terminal isn’t text. It’s a byte stream of content interleaved with control sequences — move the cursor, change the colour, clear the screen, scroll a region. Replay it faithfully and you need every byte. Show it to a human as a transcript and you need almost none of them.

So an archive is two artifacts, not one:

  • cast.gz — asciicast v2, the raw byte stream. Faithful replay material.
  • transcript.ndjson.gz — extracted, human-readable rows, which drive the Detailed, Clean and Narrative view modes in the app.

Both gzipped, both stored in Azure Blob Storage.

The spinner problem

Extracting a readable transcript is where this stopped being routine.

AI coding agents render with Ink, which repaints constantly. A spinner reading Osmosing 12 tokens becomes Osmosing 13 tokens a moment later. Naive extraction produces a transcript that is 90% spinner frames.

The obvious fix — deduplicate identical rows — fails, because counter-bearing spinners have unique text on every frame. There is nothing to deduplicate.

The fix that worked came from the terminal protocol itself. Modern terminals support synchronised output: mode 2026 brackets a coherent frame with a begin and end marker. I snapshot only at frame end, never mid-update, and then apply two rules:

Snapshot strictly above the cursor. The cursor row is where the live spinner lives. Capture the cursor position before feeding the end-of-frame sequence to the emulator, then take only the rows above it.

Require a row to be stable across two consecutive frames. Compare each row to the previous frame’s row at the same index, and emit only rows whose text matched twice. A spinner never matches itself. Real output always does.

There was a third trap underneath. My first dedup key was a marshal of the row including its attributes — and some agents render rainbow-gradient spinners where every character cycles colour. The visible text is identical; the marshal is unique every frame. The dedup key has to be the text alone.

Audience is the security boundary

Uploading the archive is a three-step flow: initialise, upload the two blobs, finalise. The PTY agent — running inside the user’s container — does the uploading.

That container already holds a JWT, because it needs one to talk to the backend. The tempting move is to reuse it. I didn’t, and the reason is the part I’d want a reviewer to look at.

Both tokens are RS256, signed by the same key. If the upload endpoints accepted any signature-valid token, then the live PTY token and the upload token would be valid against each other’s surface. A token minted for one purpose would be replayable against the other. Signature validity alone proves nothing about intent.

So the two token types carry distinct audience claims — one for the live terminal connection, one for archive upload — and each side validates only the audience it expects. The audience claim, not the signature, is the actual boundary. The upload token is scoped to a single archive and expires in minutes rather than hours, because the surface it opens accepts arbitrary blob bytes, and a long TTL is a long replay window.

RS256 KEY SIGNS BOTH aud = terminal TTL HOURS aud = upload TTL MINUTES TERMINAL SOCKET UPLOAD ENDPOINT 401 wrong aud
fig.01 — the same key signs both tokens, so only the audience claim decides where one may be spent

The bug I found by asking "what if this token comes back?"

Blob names are deterministic — derived from the user ID and archive ID. That’s deliberate: it makes storage predictable and cleanup simple. It also creates a problem.

An upload token stays valid for its full lifetime, including after the archive is finalised. Replay a still-valid token after finalisation and it would write to the same deterministic blob name — overwriting a completed archive with whatever the caller sent.

The row-state check I already had ran after the upload. It would reject the state transition, but only once the object on Azure was already corrupted.

The fix is a pre-flight check: read the archive row and return 409 if it isn’t still pending, before any bytes are streamed. The post-upload check stays, as the canonical guard on row state.

I want to be precise about what that does and doesn’t achieve. It closes the practical window, not the theoretical one — there is still a gap between the pre-flight read and the write. Eliminating it entirely needs Azure conditional writes with an etag precondition. I didn’t add that, because the residual window requires a malicious client racing requests against itself, and it’s bounded by the token’s ten-minute life. That trade-off is written down in the codebase rather than left for someone to rediscover. Both cases have regression tests, including one asserting the blob is untouched on a 409.

REPLAYED TOKEN · ARCHIVE ALREADY FINALISED REQUEST PRE-FLIGHT READ IS THE ROW STILL PENDING? 409 BLOB UNTOUCHED RESIDUAL RACE — READ, THEN WRITE BOUNDED BY THE TOKEN'S TEN-MINUTE LIFETIME CLOSED ONLY BY AN ETAG PRECONDITION ON THE WRITE
fig.02 — the check moved in front of the write, which closes the practical window and not the theoretical one

Deletion as a storage-layout decision

Archives are stored under a per-user prefix, with each session isolated beneath it.

That isn’t organisational tidiness. It means an operator can erase everything belonging to one user without scanning the container. When a user asks to be deleted — and under GDPR they can — the answer is a scoped prefix delete, not a migration script written under pressure.

There’s a related check at initialisation. Each user’s workspace identifier is derived deterministically, and the init handler verifies the one supplied in the request against the one derived from the authenticated user, rejecting a mismatch. Without it, an authenticated user could tag archive rows against another tenant’s workspace and quietly poison per-tenant audit.

One more thing worth saying: secret masking runs upstream of recording. The PTY filters credentials out of the output stream before the recorder sees a byte, so archived sessions never contain secrets that were masked on screen. Recording after masking would have been the easier wiring and the wrong answer.

Playback, and a small lesson about sort stability

The viewer is a real terminal in read-only mode, fed the archived bytes.

Read-only sounds trivial and isn’t. The emulator still needs to answer its own queries — colour queries, device attributes, cursor position — or it stalls waiting for replies that never come. So the input gate distinguishes user-originated bytes from terminal-internal responses, and drops only the former. The flag defaults to “user-originated” on purpose: a call site that forgets to mark an internal response causes a stall, while the inverted default would leak real keystrokes. The noisier failure is the safer one.

The bug I liked most: output from a previous session bleeding into a new archive. Rows surviving on the visible buffer were being flushed with the current timestamp rather than the time they were drawn. The fix was per-row last-write timestamps on the PTY side — and then a stable sort on the Dart side, because List.sort in Dart is not stable, so equal timestamps reordered rows. Sorting on (timestamp, original index) restored true order.

The client work

Alongside the archive I owned the Android side of the terminal client — the native integration around a vendored Termux fork, and the terminal screen itself.

The hardest problem there is one Omar and I worked out together — I drafted approaches, he pushed back, and we brainstormed the parts neither of us had a clean answer to. Codex and Gemini/Claude emit identical terminal signals but need opposite behaviour when the soft keyboard opens: Codex re-renders its whole scrollback on a resize signal and visibly slips, while Gemini and Claude reflow so their suggestions sit above the keyboard. Days went into classifying them from escape sequences, and every fix for one regressed the other.

The resolution was to stop guessing at the wrong layer. The Dart session layer already knows which tool is running, so it sends an explicit policy down to the native view instead of leaving native to infer it from escape sequences. Unknown tools get the safe default, so a tool we’ve never seen degrades to the behaviour that was already correct for most things. It’s a good reminder that a problem resisting solution often isn’t hard — it’s being solved in the wrong place.

Outcome

Live on iOS and Android, 500+ installs, and still actively maintained — the most recent update shipped 3 August 2026. Cosyra sells at $29.99/month.

Everything above went through reviewed pull requests, with tests beside every component: the asciicast writer, the transcript extractor, the upload handler, the blob cleaner, and a scrollback invariant test in the app.

What I'd do differently

Two things. I would add the etag precondition on the blob write instead of documenting the residual race and moving on. The reasoning for deferring it still holds, but I think “bounded and written down” is a weaker position than “closed”, and it turned out to be a smaller job than it looked.

And I would have built transcript extraction against recorded fixtures from each agent before writing the extractor, rather than finding the spinner behaviours one at a time in production output. Every rule in there came from a bug I could have seen in an afternoon of captures, so snapshot above the cursor, require two stable frames, dedup on text alone, all of it.

If this is the kind of problem you have, here is how I work on it. The closest thing to it here is LSI AI Team, where the question was what an agent is allowed to do rather than what it can do.


~/ init_sequence

What are you building?

Thirty minutes is usually enough to work out whether I am the right person. If I am not, you get a straight answer and a suggestion of where to look instead.