Add human login, cross-device answers, and reachable preview links - #1
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdin 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
Authenticatordriver 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.The
staticdriver is a passwordless picker over precreated accounts — the permanent answer for a household, not a stopgap. It reportsAssurance: 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-unsecuredas 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_userparks a run — releasing its worker, so a question costs nothing and survives any number of restarts — andRC-2extends 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" fromrun.started, which is how N clients drift into N notions of what the agent is doing.Screenshots
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:
RequestRunalways creates a run, so a typed reply would otherwise register a second run thatRC-2could never claim, and the user would watch their words vanish.Defects found on the way
Four, three of them pre-existing:
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 newfailed 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
oidcdriver 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 needsscripts/demo-local-model.shand 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
err.Error()verbatim — Go error strings, file paths, driver noise — to the client; they now go throughserverError, which logs the cause and returns a sentence.internal/e2e/language_test.goreads what a user would see on every reachable failure path and rejects jargon.openai-compatadapter, 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.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 implementsdocs/deferrals.md— D1/D2 closed, D6/D7 opened🤖 Generated with Claude Code