Skip to content

Add human login, cross-device answers, and reachable preview links - #1

Merged
korya merged 18 commits into
mainfrom
dmitri-feat-m4-multi-device
Aug 6, 2026
Merged

Add human login, cross-device answers, and reachable preview links#1
korya merged 18 commits into
mainfrom
dmitri-feat-m4-multi-device

Conversation

@korya

@korya korya commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Problem

You could build a site with Creo, but only from the machine it runs on. Closing the laptop mid-build and picking up a phone — the thing the product promises non-coders — did not work: nobody had a name, there was no way to answer the agent or stop it from a second device, and preview links pointed at 127.0.0.1.

Two deferrals were also overdue. Human users (D1) had slipped from M1 to "M3/M4"; the per-tenant storage quota (D2) was deferred at M1, re-owed to M2, and shipped past — the miss that motivated docs/deferrals.md in the first place.

Solution

Add a human-login surface, let runs pause for a question that any device can answer, and make preview links follow the host that asked. Identity is a seam, not a hardcoding: an Authenticator driver answers only which human just proved themselves, and a fixed service turns that claim into a Creo-native session — so swapping in OIDC later touches no code outside the driver.

Browser ─▶ /login ─▶ Authenticator (pluggable)  ─▶ IdentityService (fixed)
                     ├── static  → account picker   identity → local user row
                     └── oidc    → redirect (M5)    → mint session → Principal
                                                                        │
             every route, every handler ◀───── one Principal type ──────┘

The static driver is a passwordless picker over precreated accounts — the permanent answer for a household, not a stopgap. It reports Assurance: attributed, and that property (never the driver's name) is what the honesty banner, the audit trail, and policy branch on. What it cannot prove is enforced structurally rather than documented: the server refuses to start when passwordless accounts exist and the bind address is reachable beyond a private network, naming --allow-unsecured as the one deliberate, logged way past. Tailscale's CGNAT range counts as private, so the tailnet path needs no override.

Runs gained two states. ask_user parks a run — releasing its worker, so a question costs nothing and survives any number of restarts — and RC-2 extends to waiting, because a paused conversation still owns its project. Cancel bumps the lease generation rather than only flipping status, which is what actually stops the holder: its next append is rejected in storage, the same fencing that makes crash takeover safe. Sessions now name their state (session.state.changed + GET /v1/sessions/{id}); clients render it instead of inferring "working" from run.started, which is how N clients drift into N notions of what the agent is doing.

Screenshots

Sign in by picking a name Family-mode honesty banner

The banner shows whenever identity is attributed rather than proven. Its dismissal lives in page memory and nowhere else, so it returns on every reload and in every new tab — a warning you can permanently silence isn't one.

The agent asking a question; answerable here, on a phone, or from the CLI:

Typing a reply and tapping a choice take the same path — to the user they are the same act. This is load-bearing, not cosmetic: RequestRun always creates a run, so a typed reply would otherwise register a second run that RC-2 could never claim, and the user would watch their words vanish.

Defects found on the way

Four, three of them pre-existing:

  • Cancel didn't fence. The first implementation flipped status but left the lease generation, so a cancelled worker kept appending for up to a renewal interval — observed as a question emitted twice after a stop.
  • A test that passed vacuously. The language test's HTTP helper only decoded response bodies on success, so every error string it "checked" was empty. Caught by reverting a message and watching the test not fail; both the helper and the assertion are fixed, and the detector was re-verified by regression.
  • The client's 401 check never matched (String(err) is prefixed "Error: "), so an unauthenticated visitor saw an empty "Your sites" instead of sign-in. Invisible in M3; fatal for a picker.
  • creo account new failed on a fresh install with a SQLite out-of-memory error — the admin CLI never created its data directory, and this is now plausibly the first command an operator runs.

Deliberately not in scope

The oidc driver is deferred to M5 (ledger D6): it needs two dependencies under a stdlib-first rule and an IdP to test against, and T1 does not use it. The startup fence is what prevents that gap being shipped past silently. Asset upload (R-WEB-4) is now tracked as D7 — it was owned by no milestone, and a bakery site with no photo of the bakery is a real limit on the north-star claim.

Honest gaps

AC-14 is proven against a scripted server, not a GPU. The e2e drives the real binary through the real OpenAI protocol — including the quirk that local servers report finish_reason: "stop" while emitting tool calls, which is why the adapter trusts the presence of tool calls over the label. Whether a given local model is good at the task needs scripts/demo-local-model.sh and hardware that CI does not have.

AC-12 was audited against the escalation ladder, not against real users. Non-coder transcripts did not exist when the pass ran; the pass was sequenced last precisely so it could be transcript-driven, and it wasn't. Worth re-running once they exist.

Other Changes

  • Error language (R-AGT-2/3), now enforced by tests. 22 handlers returned err.Error() verbatim — Go error strings, file paths, driver noise — to the client; they now go through serverError, which logs the cause and returns a sentence. internal/e2e/language_test.go reads what a user would see on every reachable failure path and rejects jargon.
  • openai-compat adapter, written against the wire protocol rather than a vendor SDK, so one adapter covers Ollama, LM Studio, vLLM, OpenRouter, and OpenAI. Zero new Go dependencies.
  • Storage quota is CAS-aware: an unchanged logo across ten versions is counted once, and content the tenant already holds is free — otherwise a tenant near their limit could never save any change at all. The check runs before any bytes are written, so a refused commit leaves nothing behind and retrying doesn't dig the hole deeper.
  • R-NFR-2 measured at ~2 ms for a returning client against a 3-second target, as a regression alarm rather than a benchmark.
  • docs/deploy/lan-and-tailscale.md — operator guide for both networking paths, including what the platform refuses and why the check catches mistakes rather than attackers.

Related:

  • docs/plans/m4-blueprint.md — the validated plan this implements
  • docs/deferrals.md — D1/D2 closed, D6/D7 opened

🤖 Generated with Claude Code

korya and others added 18 commits August 5, 2026 23:21
Validated plan for the milestone: the Authenticator seam + static driver,
waiting/cancel interaction primitives, LAN/Tailscale reachability, the
openai-compat adapter, and deferrals D1 (users) + D2 (storage quota).

Scope decisions taken during planning: oidc defers to M5 (new ledger entry
D6 — it needs two Go deps and an IdP to test against, and T1 doesn't use
it); static accounts are CLI-administered DB rows rather than a config
file; run cancel (R-RUN-4, previously owned by no milestone) is in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The pluggable part is login, not tokens (components.md §11): an
Authenticator driver answers only "which human just proved themselves",
and the fixed Service maps that claim onto a canonical local user row and
mints a Creo-native session. External credentials never flow past the
login step, so the rest of the platform consumes one Principal type
regardless of how the caller signed in — swapping in oidc at M5 touches
no code outside the driver.

The static driver is the permanent T1 answer, not a stopgap: a
passwordless picker over precreated, mutually trusting accounts. It
reports Assurance "attributed" — the property every policy, banner, and
audit line keys off, never the driver's name.

user_identities links (issuer, subject) to users from day one so a future
IdP migration relinks rather than orphans attribution history. Sessions
roll a 90-day expiry and hash at rest like api_tokens. Events gain an
actor column (R-SEC-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Account-switch login is honest only where the network is trusted, so the
platform enforces that structurally rather than documenting it: a static
account list plus a globally reachable bind refuses to start, naming
--allow-unsecured as the one deliberate way through. Loopback, RFC1918,
CGNAT (Tailscale), ULA and link-local all pass; 0.0.0.0 is judged by the
interfaces it would actually expose.

Enforcement lives outside the driver, at the point that instantiates it,
so no driver can forget to be checked — the same shape as
ValidatePalette. An active override is logged at startup and surfaced on
/healthz.

Accounts in more than one tenant are refused too: a second untrusted
human is the definition of T2, which static login does not serve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The auth middleware was bearer-token-only; it now resolves API tokens,
web-session cookies, and the --insecure dev mapping at the same single
chokepoint, handing handlers a Principal instead of a tenant string.
Handlers never learn how the caller authenticated.

Adds the login routes the picker needs (begin/complete/logout/me) and
attributes API-written events to the acting user.

Browsers no longer need the token-in-URL concession for SSE: a
same-origin EventSource carries the session cookie natively. The query
param stays for token clients, which cannot set headers on EventSource.

e2e covers the human path end to end (sign in, build, log out), a second
device seeing the same projects, cross-tenant 404 via cookie, and
disable-revokes-live-sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Accounts are administered like tenants and tokens: local commands
against the data dir. `account new` also writes the static identity link,
so a new name is immediately loginable.

The admin commands now create the data directory when it is missing —
`creo account new` is a plausible first command on a fresh install, and
it failed with a SQLite out-of-memory error instead of just working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The key screen becomes the account picker: names and colours, no secrets
to paste. Token entry stays behind an operator link for CLI-shaped use.

The family-mode banner shows whenever the platform reports "attributed"
identity — the honest T1 statement that anyone who can reach the server
can be anyone. Dismissal lives in page memory and nowhere else, so it
returns on reload and in every new tab; a warning you can permanently
silence isn't one.

Fixes a 401 check that never matched: String(err) on an Error is
prefixed with "Error: ", so the sign-in fallback never fired and an
unauthenticated visitor saw an empty "Your sites" instead. Errors now
carry the status as data (ApiError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
A run can now park on a question and be stopped by the user, without
either becoming a special case elsewhere.

Await releases the lease, so a question costs no worker and no timer
races the human — a parked run survives any number of restarts, because
recovery only rescues leases and a waiting run holds none. RC-2 extends
to waiting: a parked conversation still owns its project, so no sibling
run starts underneath it and mutates the same site.

Cancel bumps the lease generation rather than only flipping status. That
is what actually stops the holder: its next append is rejected in
storage (SL-3), not merely its next renew. Without the bump a worker
mid-tool-call keeps narrating a stopped run for up to a renewal
interval — observed as a question emitted twice after a cancel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
ask_user parks the run and emits the question verbatim as userText. The
answer is paired back to the tool call when the conversation is
reconstructed, so a resumed run sees an ordinary completed tool call —
the pause is invisible to the model however long it lasted.

Work done before the question is committed first: a user who takes a day
to answer still has everything built so far. Extra simultaneous
questions get an ordinary tool result rather than dangling.

The profile tells the model to decide routine matters itself and ask
only when the answer changes the user's outcome (P7).

Also fixes resume detection, which treated "any prior event carrying
this run id" as evidence of a restart. Lifecycle and state events carry
the run id too, so a first run could announce itself as resumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Adds POST /v1/runs/{id}/input and /cancel, plus GET /v1/sessions/{id}
reporting the state a client should render — including any pending
question, so a device that joins mid-conversation paints the truth
without replaying the log.

A message sent while a question is pending IS the answer. Without that,
the reply would register a run RC-2 could never claim (the parked
conversation owns the project) and the user would watch their words
vanish. Typing and tapping a choice are the same act to the user, so
they take the same path.

session.state.changed is emitted at each transition (R-SES-5). Clients
render state; they no longer infer "working" from run.started, which is
how N clients drift into N notions of what the agent is doing.

e2e covers the whole choreography: a second device answering, a typed
reply landing as the answer, a question surviving kill -9, and a cancel
that leaves the last version standing while writing nothing further.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
A question renders as a card in the transcript with its choices one tap
away; free text answers it too. The answer appears from the event
stream, not an optimistic echo, so it reads identically on the device
that gave it and the one that didn't.

Session state now comes from session.state.changed and the session
endpoint on open — the client no longer decides for itself that
run.completed means idle. Stop appears only while work is in flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Written against the wire protocol rather than a vendor SDK, because one
adapter then covers every self-hosted server — Ollama, LM Studio, vLLM,
llama.cpp — plus OpenRouter and OpenAI itself. Selected with
openai:<model-id>[@<base-url>]; an absent key is normal, since local
servers want none.

Two protocol differences are the whole adapter: tool arguments travel as
a JSON-encoded string rather than an object, and tool results are
top-level messages rather than blocks inside a user turn.

One rule earned from how local servers actually behave: finish_reason is
not trustworthy. Several report "stop" while emitting tool calls, and
believing them would end a build halfway through — so the presence of
tool calls decides and the label only breaks ties. The e2e server
reproduces that quirk deliberately.

e2e drives the real binary through the real protocol end to end: tool
round-trip, version produced, usage metered (budgets must bind every
provider, not only the one we tested), and the published site serving
the built content. It cannot judge whether a given model is any good at
the task — scripts/demo-local-model.sh is that gate, and needs a GPU.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Closes the deferral that was made at M1, re-owed to M2, and shipped
past — the reason docs/deferrals.md exists.

Usage counts each blob once, because content is addressed by hash:
billing a user ten times for one logo they never touched would be a lie,
and a tenant near their limit could otherwise never save any change at
all. Only content the tenant does not already hold counts toward a
commit's increase, matching how the store actually grows.

The check runs before any bytes are written. A refused commit that still
wrote its blobs would let a stuck tenant dig deeper by retrying — which
is exactly what people do.

ProjectStore learns nothing about tenants: it hands the content-
addressed manifest to a hook, the same shape as the model gateway's
budget check. Refusal reaches the user as "There's no room left to save
more changes", and the e2e asserts no jargon leaks into that sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Preview and publish URLs were built from the boot-time serving address,
so a phone on the LAN received http://127.0.0.1:8081/... — an address
that resolves to the phone itself. They are now derived from the host
the request arrived on, with the serving port swapped in; an explicit
--public-url still wins, which is the Tailscale and reverse-proxy case.

The Host header is attacker-controllable, so it is used for exactly one
thing: formatting a link returned to that same caller. It never reaches
auth, routing, or storage — a lying Host hurts only the liar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
AC-4: a laptop starts a build and stops being used; a phone signs in
fresh, learns the state from the platform rather than guessing at the
transcript, answers the pending question, and the laptop — reconnecting
from its own cursor — sees the answer it never sent.

Also pins cursor backfill against the exact unseen slice (no gap, no
overlap, no duplicate), and records what a returning client waits for.
R-NFR-2 targets p95 under 3s; the measurement lands around 2ms, because
log-first resume has nothing to warm up. It is a regression alarm, not a
benchmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Operator guide for the two supported paths (PRD open question #4): bind
a LAN address for same-network use, or join a tailnet for anywhere —
including how to let Tailscale terminate TLS so the app runs in a proper
secure context.

States plainly what the platform refuses and why, that the check catches
mistakes rather than attackers, and that plain-HTTP LAN is a real
degradation rather than a shrug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
The error-language pass, and it found two real leaks.

Twenty-two handlers returned err.Error() verbatim on failure, putting Go
error strings — file paths, SQL, driver noise — on a non-coder's screen.
They now go through serverError, which logs the cause with its route and
returns a sentence. The client rendered String(err), which prefixes the
class name and status code: "ApiError: 409: ...". Errors now carry the
platform's sentence separately, and only that is ever shown.

Messages a person can actually reach were rewritten in their own words:
publishing before anything is built, restoring past the first version, a
sign-in that expired, a link to something gone.

language_test.go locks it in by reading what a user would see on every
reachable failure path. Its first version passed vacuously — the test
client only decoded response bodies on success, so every error string it
"checked" was empty. Both the client and the assertion are fixed, and
the detector was verified by reverting a message and watching it fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
Records M4 as delivered, with its two honest gaps stated rather than
implied: AC-14 is proven against a scripted server rather than a GPU,
and AC-12 was audited against the ladder because real non-coder
transcripts did not exist yet.

Closes deferrals D1 and D2 with the commits that did it. Opens D6 (the
oidc driver, owed M5 — until then the startup fence is what stops
passwordless login being shipped above T1) and D7 (asset upload, which
no milestone ever owned: a bakery site with no photo of the bakery is a
real limit on the north-star claim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
`just test` advertised itself as the fast inner loop that skips the
subprocess tests, and passed -short to say so — but no test in the e2e
package ever checked testing.Short(), so all 29 ran anyway. The recipe
took ~14s and built a binary; only its documentation was fast.

TestMain now exits early under -short, before the build. The inner loop
drops to ~1.4s. `just test-full` and CI are unaffected: they don't pass
-short, and the full suite still runs all 29.

Also records what the package is for at the top of the file — black-box
over the public API, real failure injection, and never a real model
provider — since that last property is what lets CI run key-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiRDTC3E7HGD6Cbq8QeFXM
@korya
korya marked this pull request as ready for review August 6, 2026 13:42
@korya
korya merged commit a31d963 into main Aug 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant