The ~500-char opc1_ fragment is retired from every production surface; a
relay session now shares one 10-char Crockford code (1 region char + 9
random, 45 bits). The full invite is sealed with a blake3 encrypt-then-MAC
under keys derived from the code and stored on the locator control plane
under an independent code_id; guests claim from exactly the region the
code names, so neither the id nor the bearer ticket reaches uninvolved
regions. The store tombstones exhausted claim budgets (an id can never be
re-published under a burned code), caps codes per device key, and keeps
per-route ingress body limits. Connect failures now distinguish invalid,
expired, and relay-not-configured across all 15 locales, and the threat
model documents the operator-grindable 45-bit residual risk.
A pasted invite code now replaces the whole field instead of appending to
stale content, Cmd/Ctrl+A selects the field so Backspace/Delete clears it
in one stroke, and a clear button sits inside the input. IME commits keep
insert semantics; only clipboard paths replace. Every blur and navigation
arm drops the whole-field selection so no destructive replace-on-type
state survives a click.
Two halves of the same problem: the relay learned who collaborates with
whom, and a guest could not safely join a stranger's session without an
invite.
Claim-minimized relay bearer. The relay authenticated each WSS connection
with the full collaboration ticket, whose claims carry the account subject,
device id, and optional display name and avatar. Now that collaboration is
cross-account, that let a relay operator reconstruct a social graph. The
relay reads exactly one field out of that ticket — the expiry it clamps the
session deadline to — and its authorization output, (route, role, expiry),
comes from the signed locator and the route capability, not from identity.
The disclosure was gratuitous.
A separate audience-scoped token now carries only issuer, audience,
version, scope, the channel binding to the caller's X25519 key, and the
time bounds. `VerifiedRelayTokenClaims` deliberately exposes no identity
accessor at all, so the relay cannot regress into reading one. No route or
role claim was added: route authorization already comes from the locator
plus the capability secret, and putting route ids in the token would move
the graph to the issuer, which also knows the account.
Scope of the guarantee, stated in the code so nobody over-reads it: this
defends against a third-party or regional relay operator. It does not
defend against the first party, who runs both the issuer and the relay and
can rejoin on the channel-binding key and the issuance time. It also
de-identifies rather than making the view unlinkable — the device's X25519
static is persistent and in the clear in every hello, so the operator still
builds a device graph, it just cannot name the nodes or join them to the
account namespace.
The two token types are domain-separated by both JWS `typ` and `aud`,
strictly compared, with `deny_unknown_fields` on disjoint claim structs, so
each is structurally invalid against the other's parser. That property is
what makes sharing one signing key defensible, so it is tested in both
directions. The relay dual-accepts during migration, discriminating on
`typ` before claim parsing, behind an env flag. The client never retries a
rejected minimized token with the full ticket — that would be a downgrade
any curious relay could trigger at will.
Guest owner confirmation. A guest joining over unpinned LAN discovery still
required the same account, because it has no approval prompt of its own and
mDNS names nobody. It now gets the explicit decision the owner already had:
the verified owner identity is surfaced and confirmed before the peer is
authorized, so nothing from the session — snapshot, presence, session name
— exists before the user decides. With that gate in place the unpinned LAN
path admits any issued account too.
Display name and avatar are attacker-chosen, so the projection separates
them from the account subject and device id at the type level, strips
invisible and bidi-control characters, and labels them as claimed. A
display name cannot occupy an authoritative row; the test uses another
account's UUID as the display name to prove it.
Both halves are pinned in the boundary gate: losing either asymmetry is
silent, because the code still compiles and every other check still passes
while nothing authenticates the peer.
Peer admission required the remote ticket's subject to equal the local
account on both sides, so only devices of one account could pair. That
made the product multi-device sync rather than collaboration.
The subject equality was the authorization, so it is replaced rather than
deleted. `PeerIdentityPolicy` states which accounts a peer may belong to,
and the two sides get different answers because they do not have the same
ability to tell who the peer is:
- The owner accepting a guest admits any issued account. Nothing at this
layer decides whether the guest joins — a human does, from the approval
prompt, which is shown the verified identity and which the admission
state machine makes unskippable (`Active` is reachable only through
`OwnerAuthorized`).
- A guest joining by invite or relay admits any issued account, because
the invite's signed locator already pinned the owner's Noise static key
and that pin is checked before admission runs. The device is
authenticated whatever account is behind it, which is what makes joining
a stranger's session safe.
- A guest joining over an unpinned LAN discovery still requires the same
account. A guest has no approval prompt — whatever it accepts, it
accepts silently — and nothing else names the peer there: mDNS is
spoofable and no key is known in advance. Relaxing it would let anyone
on the segment holding any valid ticket pose as the owner, undetected.
Opening this needs a way for the guest to confirm who it is joining,
which is a user-facing decision, not a protocol change.
Relaxing the account relaxes nothing else: issuer, expiry, and the binding
to the observed Noise static key are unchanged, and renewal still refuses
any mid-session change of issuer, subject, device id, or key. Tests cover
that a foreign subject is admitted while each of those still rejects, and
the unpinned-LAN case has its own regression guard.
Also fixes two things this uncovered. The live MCP tests in op-host-desktop
drove the endpoint with raw HTTP and no token, so they failed with 401
after bc75c765b authenticated it — they now send the instance token, which
is also a live check that the authentication works. And two constant
relations in the transport config were runtime asserts that clippy rejects
as constant-valued; they are compile-time asserts now, which is what a
relation between constants should have been.
Pure code motion restoring the workspace file-size cap after the
conflict-stash feature pushed four files over it:
- op-editor-core: notice kinds and the discarded-edit projection move from
collab_ui_state into a new collab_notice_ui module; collab_ui_state
re-exports them so import paths stay stable.
- op-host-desktop: the local-edit gestures (begin/finish/reapply) move to
collab_runtime/local_edit; the discarded-edit stash handling moves to
collab_runtime/discarded_edit; the fail-closed paths and failure-notice
mappings move to collab_runtime/failure; the owner-retaining guest test
harness and the conflict e2e test move to collab_runtime/conflict_tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The live MCP endpoint on 127.0.0.1 was a bypass of the collaboration
admission model. Its per-instance token authenticated only the ping probe
and shutdown, so document reads and writes were available to any local
process, and nothing validated Origin or Host — a page in a browser on the
same machine could reach it by DNS rebinding. During a session that is the
shared document, not just this user's file.
Every stateful call now requires the instance token, compared without an
early exit. Host must be a numeric loopback literal on the bound port, and
an Origin, when present, must match it; a request with no Origin still
works, which is what real CLI clients send. OPTIONS, initialize, and ping
stay tokenless so CLI discovery keeps working, and CollabGatePolicy is
untouched — this sits in front of it.
The `op` CLI did not send the token, so authenticating tool calls would
have returned 401 for every `op` invocation against a live editor. The
token was already in the port file next to the port; it is now resolved
with the port and travels as a header. Ping and shutdown keep their
existing tokenless wire contract.
Bootstrap cache: reads now degrade like writes already did. An unreadable
or corrupt cache leaves this start with no anti-rollback generation floor,
which is the position an absent cache has always left it in, and which the
threat model already accepts because deleting the file achieves the same
thing with no more privilege than corrupting it. Refusing bought no
security and cost the ability to collaborate at all. The tests state the
price plainly: with no floor the lower-generation document is accepted,
and `rollback_floor_armed` has to report it.
Threat model: correct an overstatement. Peer admission requires the remote
ticket's subject to equal the local account, so the product pairs only
devices of one account today. A relay operator reconstructs which devices
of an account sync and when — not a cross-account collaboration graph.
Also records that the relay reads exactly one field out of the ticket it
verifies, the expiry, which makes the identity disclosure gratuitous
rather than load-bearing, and states what a minimized credential would and
would not buy.
A guest edit that loses a collaboration conflict is no longer silently
dropped behind a generic toast. The cancellation now carries the dropped
EditChanges end-to-end: the conflict notice names the discarded nodes and
fields (dedicated EditConflictDiscarded notice kind so the detail can never
attach to an unrelated conflict toast), and the dropped property intent is
stashed so the collab panel can resubmit it on request through the new
op_collab::reapply_property_changes API. The stash is created only for
genuine concurrency losses (property conflict / precondition failure) —
policy, permission, and size rejections map to their own notices — and is
cleared on every session-Ended path so it cannot outlive its session.
Addresses one blocker (replay consumed the stash before acquiring the edit
lane), four concerns, and one nit from external review. Verified by
op-host-desktop collab (136), op-editor-core (19 collab), op-editor-ui
(52 collab), op-collab, and op-i18n suites.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Follow-up to 0649cf40, which split renewal concurrency away from initial
authentication so an unauthenticated flood cannot starve live tunnels.
`online_challenge_timeout_keeps_its_auth_concurrency_permit` encoded the
old coupling as its contract — it saturated `max_auth_in_flight` to make a
second online challenge contend — so after the split nothing contended and
the test hung the whole suite. The property it covers (a timed-out online
challenge neither leaks its permit nor spawns another blocking task) is
unchanged; it now saturates the renewal budget that actually governs that
path. Its initial-path sibling was already passing and is untouched.
Roster: add `release_disconnected_peer`. A departed guest is retained for
same-epoch resume, but that retention is unbounded and counts against
`max_participants`, so a session that churns through guests eventually
admits nobody and each retained peer holds its result window until the
epoch ends. Releasing is refused for the owner and for a still-connected
peer, and it does not weaken id-space safety: a namespace can only be
reissued when the document carries none of its ids, which is checked
against the document rather than the roster.
Tickets: replace the derived `PartialEq` on `OpaqueTicket` with a
constant-time comparison. Nothing compares a ticket against a stored
secret today — verification lives in the host auth layer — but the derive
inherited `String`'s early-exit compare, so any future `==` on
attacker-supplied input would have been a timing oracle.
Bootstrap cache: a failed write now degrades instead of failing the
bootstrap. The document was already fully verified; what a failed write
costs is the anti-rollback generation floor for the next start, and the
threat model already accepts a missing floor because deleting the cache
has the same effect. An unwritable configuration directory must not mean
"cannot collaborate". The degradation travels with the document rather
than passing silently. Reads stay fail-closed: an absent cache reads as
`Ok(None)`, so only a cache that exists and is corrupt or unreadable
takes that path, and that is an anomaly rather than a first run.
Threat model: document what the relay operator actually sees. The bearer
ticket it verifies carries the account subject, device id, and optional
profile claims, so it learns who collaborates with whom — the asset table
previously implied those reached session participants only. Also adds the
invite-as-bearer-capability lifecycle and the local MCP endpoint, which
during a session reads and writes the shared document outside the
admission story. The relay-client doc comment no longer claims the relay
"sees only bytes".
Addresses a security review of the collaboration subsystem. No auth
bypass, key leak, or document-plaintext exposure was found; every
finding below is availability or trust-boundary hardening.
Landed as one commit because the pieces are not separable: the
inbound-direction ceiling spans op-collab, op-collab-transport, and the
desktop host atomically, the guarded accept spans transport, smoke, and
the desktop host, and the boundary-gate rules only hold against the
final state. Splitting would produce commits that fail to build or fail
the gate.
Relay server (public, internet-facing):
- Charge pre-pairing capacity per source address. The auth-concurrency
semaphore was taken before the WebSocket upgrade and the peer address
was discarded, so one host could pin every permit by connecting and
going silent.
- Give renewals their own budget. Reauthentication competed for the same
semaphore, so an unauthenticated flood progressively closed live
tunnels with a policy error.
- Release the pair registration when the ready status fails to send; the
counterpart only reclaims it if it reads its pairing notice.
- Require the X25519 key file to be owned by the running user; mode bits
alone do not establish trust.
- Summarise capacity rejections instead of logging one line each.
Locator service:
- Rate-limit publishes per client instead of process-wide. One
unauthenticated caller could consume the whole budget and 429 every
tenant's invite issuance.
Collaboration protocol:
- Size the inbound envelope ceiling from the authenticated remote role
rather than sharing the 64 MiB snapshot ceiling in both directions, so
an admitted guest cannot force a 64 MiB JSON parse per frame. The
ceiling is applied before the discriminator and before the generic
value decode; a peer-declared snapshot kind cannot raise it.
- Reject display names carrying Unicode format characters, which render
identically to an existing participant's name.
- Reject avatar URLs pointing at non-globally-routable addresses.
Transport:
- Reclaim a pending-handshake seat from a peer that has not produced a
valid first handshake message, and raise the global ceiling. Sixteen
seats held for the full handshake window let four addresses deny every
join.
- Put inbound reassembly under an aggregate budget; only the outbound
aggregate was bounded.
- Stop heartbeats from refreshing the idle deadline in receive_transfer.
- Filter IPv4 link-local discovery advertisements, matching IPv6.
Relay client and trust roots:
- Bound server-initiated reauthentication per connection by count and
minimum interval, sized from the protocol's own cadence.
- Close the policy-file TOCTOU window by identity-checking the opened
file, and reject group/world-writable or foreign-owned policy files.
- Stop discarding bootstrap cache-write failures, which silently
disabled the anti-rollback generation floor.
The catalog shipped with the mobile-app and freeform galleries only, so
four of the six categories opened empty — a filter chip that leads
nowhere reads as a broken panel, not an empty one.
Adds eight entries covering the remaining categories (two web pages, two
dashboards, two component systems, two modify instructions), each with
its bilingual body, a generated preview, and a 15-locale title key. The
catalog-size constant moves 1255 -> 1263 accordingly.
Starter prompts gain previews too: they were the first thing a new user
sees and were the only category rendering as bare text.
The native raster test now decodes four of the new previews instead of a
single hard-coded one, so a re-generated asset that regresses decoding
is caught for more than one image.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The harness could only drive fresh generation, so the modify-category
prompts had no way to produce before/after thumbnails: their whole point
is what changes about an EXISTING document.
`OPENPENCIL_SMOKE_MODIFY_INPUT=<baseline.op>` runs a real document
through the same `build_modify_plan` -> `run_modify_turn` -> scoped host
apply path the desktop uses, so a thumbnail reflects the shipping code
rather than a harness-only shortcut.
The baseline is never overwritten — the output must go to a distinct
`OPENPENCIL_SMOKE_OUT` — and both files are SHA-256 addressed in the
summary, so a thumbnail set can prove every modify prompt started from
the identical input.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
A node whose stock search came back empty was parked in a terminal
failure state with no way back, so a design shipped with placeholder
slots even when a second attempt would have found art. Split the retry
policy out of `image_enrich_cli` into its own `retry` module and give
the session an explicit `retry_search_failures` entry point that
re-admits those nodes for a bounded, caller-managed retry.
Only Search/Auto nodes are re-admitted: an explicit Generate target that
failed is never silently converted into a stock search, since that would
substitute different art than the design asked for. `image_request_mode`
makes that distinction a property of the node rather than something each
call site re-derives.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`plan_is_sidebar_dashboard` accepted a sidebar signal from ANY subtask,
so a landing page whose plan happens to carry a nav/menu section was
built on the two-column dashboard scaffold — a sidebar rail down the
left of a page that should be a full-width hero stack.
Require the signal to come from the FIRST subtask (a real sidebar is the
leading section, not an incidental one), and let a plan's landing-page
anatomy veto an ambiguous signal via `plan_has_landing_anatomy`. An
explicit landing-page request now vetoes every dashboard signal, while
an explicit dashboard or admin-console request still wins ahead of the
structural check, so the unambiguous cases are decided by what the user
asked for rather than by section keywords.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
A prompt that names its canvas size ("1200x800", "390 宽") had no path
to the root frame: planning always applied the desktop/mobile defaults,
and cleanup was free to grow the root past whatever was asked for.
Parse the request once (`request_dimensions`), apply it during plan
normalization, state it in the compact prompt so the model builds to the
same number, and carry a `preserve_requested_root_height` flag into
cleanup through an explicit `CleanupPolicy`. The policy defaults to the
historical behavior — only the fresh-root orchestrator path opts in — so
append and modify runs are untouched.
Cleanup needs the RESOLVED height to honor that flag without collapsing
real content, so `geometry_validation` grows `resolved_node_height`,
measuring the laid-out subtree against the node's own top edge rather
than trusting the declared value.
Planning corpus follows: the desktop sizes are labelled "Desktop
default", so an explicit request reads as an override rather than a
contradiction.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The line grammar anchors every pattern on `\)$`, so an operation
written as `img=G(...),` was rejected as unparsable. A trailing `;` was
already stripped; a `,` was not — and the comma is the costlier miss,
because a model reaching for a list separator writes it on EVERY line.
All lines fail, the transaction rolls back, and nothing lands.
Measured 2026-07-31: five `G(...)` image fills rejected for one trailing
comma each. `Cannot parse operation` echoes the line without saying what
is wrong, so the model mis-diagnosed it as an ARGUMENT separator
problem, then started deleting and rebuilding subtrees it had already
committed — chasing node ids that were never stale, since ids are string
identities and a delete renumbers nothing. The design ended up worse
than before the failing batch. One rejected line is a retry; a rejected
batch is a demolition.
Only the line's own tail is trimmed, so a comma inside an argument body
is untouched: every real operation ends on `)`.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`effective_path_env` always let the login shell's PATH lead. That is
right for a Dock/Finder launch, where the process inherits launchd's
stock PATH and the login shell is the only place the user's toolchain
exists — but wrong whenever the process PATH was customised, because
then it *is* the user's live intent and we were silently overriding
which binary gets resolved.
Measured: with two `codex` installs (an old npm global under homebrew
and a current one under nvm) and a `.zshrc` ordering homebrew first, the
app resolved the old binary while the user's terminal resolved the new
one. It reported a stale model catalog and, worse, rewrote the shared
`~/.codex/models_cache.json` with its own outdated list, so even the
cache fallback went backwards.
Decide the merge direction on a fact rather than a guess: if every entry
of the process PATH is a stock system directory it carries no intent and
the login shell leads; otherwise the process PATH leads. Login-only
entries are still appended either way, so nothing that used to be
reachable stops being reachable.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
`model_profile` declares `deepseek-v4-pro { thinking_disabled: true }`,
but the wire layer decided whether to actually send
`thinking:{"type":"disabled"}` from a model-name allowlist that only
covered MiniMax and GLM. DeepSeek matched neither, so the declaration
was silently dropped and every agent-loop turn leaked reasoning until
`max_tokens` ran out — which truncates a `batch_design` mid-JSON while
leaving the short read-only tool calls intact, so the transcript shows a
run of green tool calls and then simply stops.
The same list lived in three places (single-shot body, agent loop,
headless harness) and had already drifted: the harness matched GLM with
`starts_with` where production used `contains`, so a vendor-prefixed id
benchmarked with thinking on and shipped with it off. Collapse all three
onto `op_orchestrator::accepts_thinking_body_field`, next to the profile
table that states the intent, and add a guard test asserting every model
whose profile asks for thinking off can express that on the wire.
DeepSeek's field shape and its `effort=high` default are documented at
https://api-docs.deepseek.com/guides/thinking_mode/ — it is the same
`{"thinking":{"type":...}}` MiniMax and GLM take. Sending it
unconditionally is still wrong: a builtin provider may point at an
endpoint that rejects unknown body fields, so the table stays the
boundary and a new family is one line in one place.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The cache parser filters on `visibility`, but the app-server parser
ignored the protocol's twin field `hidden`, so the two paths that feed
the same picker disagreed on what counts as a listable model. Today the
server withholds internal entries (`codex-auto-review`) from
`model/list` on its own, so this changes nothing against the current
build — it keeps a server that starts sending them from leaking an
unusable model into the picker.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x