* fix(state): store in-root agent database registry paths relative to the state dir (schema v9)
Copied state directories retained stale absolute agent database registry rows. The combined gateway store then merged old and new copies, causing every sessions.list request to fail with SessionCanonicalKeyMigrationRequiredError.
Schema v9 stores in-root registry paths relative to the state directory; migration rewrites eligible rows, deletes stale duplicates, and preserves external paths.
* fix(voice-call): describe state-relative registry migration
* fix(state): preserve registry locator traversal
* test(macos): move PortGuardian schema-version boundary to v9
The store opens any state database up to maximumSupportedSchemaVersion,
which the schema v9 bump moved from 8 to 9. Shift the supported/newer
partition in the boundary test accordingly.
* fix(state): re-anchor copied default-layout registry rows instead of deleting them
Resolve the ClawSweeper P1 by preserving genuine external default-layout registrations, re-anchoring copied rows when an in-root counterpart exists, and deleting only dual default-layout conflicts.
* refactor(gateway): delete the retired Control UI device-auth migration window
The one-time remediation window for the retired
gateway.controlUi.dangerouslyDisableDeviceAuth break-glass threaded a pending
state machine through the connect pipeline, startup bootstrap, lifecycle,
request context, device management, security audit, the hello frame, and a
Control UI banner flow — steady-state runtime carrying a retired-shape shim
the architecture reserves for doctor. The window is closed: the retired key
is now fully inert, doctor still detects and removes it, and a browser that
never completed the migration pairs through the normal device flow (one
approval, no data loss).
Deleted with it: the config-machine-state import of the legacy flag, the
hello deviceAuthMigration field (optional; clients validate envelopes only,
so older gateways emitting it stay compatible), the migration-bound device
management authz states, the requireNoPairingCapableOperator approval mode,
the effective-operator pairing event emitter whose only subscriber was the
migration completion, and the Control UI banner, loader, overlay wiring,
i18n strings, and e2e scenario support. Swift and Kotlin protocol models
regenerated.
Live-verified on an isolated gateway with the retired key present in config:
clean boot with no migration warning, silent CLI pairing and silent local
scope widening unaffected, and doctor reporting the key as retired-and-inert.
Net -1111 production LOC.
* fix(ci): finish migration-window cleanup surfaced by the gates
The doctor migration for the retired key kept its "Preserved for remediation"
change text and describe; both now state plain removal, with the retired
tests updated. peekStoredDeviceIdentityId lost its only production consumer
with the deleted banner loader and is removed with its test mock. Also fixes
the unrelated no-unnecessary-boolean-literal-compare lint break that #124636
landed on main in scripts/check-changed.mts (truthiness is equivalent for the
boolean-or-undefined TTY probe).
* fix(ui): drop the retired-key device-auth reader from the security summary
Review findings on the migration-window removal: the Control UI security
summary still derived its "Device auth" row from the retired
dangerouslyDisableDeviceAuth key, rendering device auth as disabled on
configurations that merely retain the inert key. Device auth is now
unconditionally enforced, so the row and its derivation are removed rather
than pinned to a constant. The build-admission test also tracks and removes
its temporary device-identity databases after each run.
* fix(skills): keep ClawHub search results on the source the operator picked
ClawHub search returns each result's origin under `install.reference`, but the
response model expected a flat `installRef`. That field is never present, so
every row fell through to a synthesized `@owner/slug` reference. External
skills.sh results were rewritten onto a ClawHub-native identity, dropping both
the commit-pinned source and the "not scanned by ClawHub" trust record.
Map the search wire shape explicitly and make the search contract
action-specific: `installRef` always names the result's own source, `detailRef`
appears only while ClawHub can serve a detail card for that identity, and
`trustState` travels with unscanned sources. Clients render install directly
when detail is absent instead of offering a review the Gateway must refuse.
Covers the Control UI, macOS, iOS Settings, iOS AgentPro, and Android, which
previously routed every row through review and could not install an external
skill at all.
* fix(skills): make install-only sources explicit and keep legacy review intact
Address review findings on the search identity contract:
- Replace the detail-reference capability with an explicit `installOnly` flag.
A Gateway released before this field omits it, and reading omission as
install-only made ordinary registry results skip the reviewed-version flow on
every client. Absence now means the existing review-then-install path.
- Parse closed source variants in the producer. A row whose source is unknown,
whose external reference is missing, or whose registry publisher is absent is
dropped instead of falling through to `@owner/slug`, which was the original
source swap in a different disguise.
- Carry the exact install reference alongside the canonical slug. The Gateway
already records `requestedReference`; the clients dropped it and matched
installs by slug, so a completed external install read back as unknown.
- Gate the direct-install action on admin rights. The row previously stayed
enabled for read-only operators and reached a guard that silently returned.
- Route the unscanned-source warning through the native and Control UI string
catalogs instead of a hardcoded literal.
* chore(i18n): leave generated native locale artifacts to the refresh workflow
Preflight isolates generated locale output from source changes: only the native
sources and apps/.i18n/native-source.json belong in a feature commit.
* fix(skills): satisfy Android ktlint wrapping and Swift test link construction
Extract the ClawHub result action guard into a named value so the multiline
condition follows ktlint wrapping, and pass the new requestedReference field in
the OpenClawKit installed-link fixtures.
* fix(skills): preserve external install identity across clients
* test(skills): add exact refs to recommendation fixtures
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* feat(workers): run device sessions from Gateway bundles
Install the current Gateway bundle before a device environment becomes ready, verify it at attach and tunnel boundaries, launch only from the immutable namespaced bundle directory, and retire stale environments for idempotent reprovisioning. Remove the local execution mode and preserve the node-local build claim only as temporary inventory metadata for the final projection/cleanup slice.
* docs(runners): record Gateway bundle cutover
* test(ci): repair runner validation fixtures
# Conflicts:
# src/scripts/test-projects.test.ts
* fix(workers): surface outdated node recovery
Keep legacy runner inventory diagnostic-only while exposing the update-and-reconnect action through node, environment, provider, placement, and Control UI surfaces.
* fix(workers): reject legacy inventory with recovery
* fix(workers): bundle worker deploy closure
* test(workers): close bundle cutover gates
* fix(workers): compose browser runtime at build
* fix(workers): satisfy bundle cutover gates
* fix(workers): route temp runtime through infra
* docs(workers): align bundle host guidance
* fix(ui): fence outdated session destinations
* feat(gateway): add remote-exec cloud placements
* feat(codex): run cloud turns through remote exec
* fix(sandbox): quote ssh_config path directives containing whitespace
Crabbox lease keys default to ~/Library/Application Support/... on macOS;
unquoted IdentityFile/UserKnownHostsFile/CertificateFile arguments tokenize
on the space and fail as 'extra arguments'. Found via live remote-exec
cloud-worker proof.
* test: consolidate gateway maintenance schedule coverage
* fix(ci): invalidate plugin sdk declarations on state changes
* fix(ui): fork active sessions from stable history
* fix(protocol): align active fork types
* test(ui): match main-session fork routing
* fix(sessions): unify stable fork admission
* fix(native): fork active sessions from stable history
* fix(android): fork active sessions from stable history
* style(android): simplify active fork result handling
* fix(native): preserve legacy fork transport API
* test(native): complete legacy fork transport stub
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(ui): complete pairing setup lifecycle
Redeemed and expired mobile setup codes stayed on screen as usable QR
codes, so a successful pairing had no visible outcome and expired bearer
material still looked live.
The Gateway now mints an opaque, non-authorizing setupId beside each
setup credential, returns its authoritative expiresAtMs, records the
terminal outcome of the exact redemption, and broadcasts
device.pair.setup.completed. Because that broadcast is dropped for
buffered operator sockets, the completion is persisted first and can be
reconciled through the new device.pair.setupStatus method: the Control UI
asks for the recorded outcome before it may present a credential as
expired, so a pairing that succeeds is never shown as a failure.
The Control UI models one closed lifecycle (selection, loading, waiting,
success, expired, error) correlated only by the active setupId, and
Pairing help now carries the external-link affordance.
* fix(ui): preserve unknown pairing outcomes
* test(ui): target pairing terminal headings
* test(ui): align pairing completion fixture
* fix(gateway-protocol): decode setup-code results from older gateways
Older protocol-v4 gateways omit the new setupId and expiresAtMs
lifecycle fields, so requiring them in the generated native model broke
decoding an existing device.pair.setupCode response. Keep both optional
at the wire boundary, require lifecycle metadata before the Control UI
enters its waiting state so a missing outcome stays visible, and cover
the legacy payload with Swift and schema regressions.
* fix(ui): surface rejected pairing dialog loads
The lazy pairing chunk could reject while its overlay was already open,
leaving the shell rendering nothing at all. Record the rejection on the
shell and render a recoverable modal with a reason and a retry so the
open action always ends in a visible outcome.
* fix(state): preserve pairing setup schema compatibility
* test(gateway): cover pairing setup release train
* fix(gateway): commit pairing setup completion atomically
* refactor(state): distinguish setup transaction helpers
* refactor(state): remove obsolete bootstrap restore path
* fix(gateway): preserve setup handoff type safety
* fix(gateway): keep pairing completion terminal after consume
* fix(gateway): validate Watch binding during setup commit
* fix(gateway): revalidate setup credential expiry at commit
* chore: refresh Plugin SDK API contracts after rebase
* fix(pairing): prune expired setup completions
* chore: retrigger CI
* fix(protocol): deduplicate setup expiry field
* fix(protocol): refresh pairing setup clients
* fix(gateway): make pairing setup completion durable
* fix(ui): retire expired pairing credentials immediately
* fix(ui): keep pairing dialog visible while loading
* fix(macos): align setup result initializer order
* fix(gateway): restore generic bootstrap retries
* chore(ui): record pairing startup budget
* chore(ui): refresh pairing startup budget
* style(gateway): format maintenance imports
* test(gateway): cover session-sharing mock
* fix(state): defer setup correlation schema
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* refactor(delivery): collapse failed-row lifecycle
Replace the unshipped failure-operations platform with payload-free terminal receipts owned by existing queue boundaries. Keep bounded/permanent idempotency only for reusable or crash-ambiguous producers, move physical expiry to queue maintenance, and preserve migration and media-cleanup safety.\n\nTogether with #123410, production code is net negative by 11 lines; tests, docs, and generated protocol mirrors are accounted separately.
* fix(delivery): break state DB import cycle
* fix(delivery): classify SQLite boundary uses
* test(gateway): mark retained health fixture
* feat(computer-use): computer.act v2 action contract with capability-filtered tool
* fix(computer-use): break contract import cycle, bound model-visible elements, regen swift protocol
* test(computer-use): satisfy curly rule in schema-cap helpers
* fix(computer-use): satisfy type-aware lint on contract and tool
* test(gateway-protocol): keep connect-params suite under the line cap
* feat(secrets): add authenticated egress substitution proxy
* feat(secrets): bind egress substitution to hosts
* ci(codeql): classify egress proxy bypass tunnel in network boundary query
* refactor(proxy-capture): use the canonical IP parser instead of node:net
* fix(secrets): compare proxy tokens with a process-keyed MAC
* fix(ui): report the outcome of a cross-device token rotation
Rotating another device's token ended with nothing on screen. The Gateway
returns the replacement only to a device rotating its own token
(shouldReturnRotatedDeviceToken; docs/cli/devices.md), so every rotation of
another device answered without a token and the Control UI, which only rendered
when one came back, showed no dialog, no message, and no error while the target
device was invalidated and disconnected.
device.token.rotate now records how it delivered the replacement in a
tokenDelivery discriminator ("in-band" | "withheld-cross-device"), additive and
optional so gateways that predate it still decode. The Devices page reports the
recorded fact rather than inferring one from a missing token: the show-once
reveal for an in-band rotation, and an outcome dialog for a withheld one that
states the token is not shown here, that the device was disconnected, that it
picks up the replacement on its next connect with a Gateway token or password,
and that a device which can only sign in with its device token has to be paired
again.
The reveal component takes an optional secret: with one, dismissal stays refused
because the value is unrecoverable; without one there is nothing to lose, so
Escape and backdrop close it like any dialog.
* fix(ui): lead the rotation outcome with what happened, not why
The withheld-rotation dialog opened on a negation, stacked three clauses of
protocol reasoning, and left "do I have to do anything?" to the last sentence.
It now reads outcome, then next step, then the one exception, with the security
rationale demoted to a muted trailing line: the device is named, told to be
signed out, and the common case says nothing else is needed. The rotate callback
carries the row's resolved label so the dialog names the same device the operator
clicked instead of rederiving the precedence.
The reveal component takes paragraphs plus an optional muted note, so an outcome
and its exception stay separate instead of collapsing into one block.
* fix(ui): give the rotation outcome a visual hierarchy
Four near-equal paragraphs in a plain box read as one undifferentiated block:
the reassurance, the conditional branch, and the footnote all carried the same
weight, and the calm text was rendered in muted grey because it reused
.exec-approval-sub.
The dialog now has zones. A success mark in the shared icon-tile geometry sits
beside the title, the two reassurance sentences carry full --text weight, the
one conditional branch becomes a house .callout.info so it cannot be skimmed
past, and the security rationale is a 12px muted footnote. Spacing follows the
card's 4px rhythm.
The acknowledge button follows the confirm-dialog convention: the accent button
is the action that commits something, so a show-once reveal keeps it and a
report of work already done uses the neutral button.
* fix(ui): collapse the rotation outcome to a single reassurance
The body zigzagged: it announced a new token, then raised a disconnect, then
spent the next line walking that alarm back. The middle beat existed only to be
contradicted, so it is gone -- the disconnect is transient and self-healing, and
naming it bought the reader nothing.
The title now carries the announcement and the device name, the body is the one
reassurance, and the callout drops its "this device" echo. The neutral dismiss
button gets --border-strong: .btn's resting border is within ~4/255 of --card in
dark, so it read as unpainted on this surface. The callout wraps balanced.
Both dialogs now pass a single message, so the paragraph-array support added for
the previous structure is removed rather than left speculative.
* fix(ui): key the rotation callout to an observable symptom
"If it can only sign in with its device token" asked the operator to know which
credential the device holds, which is internal state they cannot see. The
condition is now the symptom that state produces: the device does not come back
on its own. Same behavior, same technical truth, decidable from the Devices page.
* fix(ui): reject contradictory device-token rotation results
The Devices page inferred the outcome from an unchecked RPC payload, so an
explicit `tokenDelivery: "in-band"` with no token, a withheld result that
carried one, or a delivery mode this client predates all rendered as a
successful withheld rotation - telling the operator the device re-credentials
itself while their previous token was already invalid.
rotateDeviceToken now parses the response into the closed outcome: the two
current pairs, the two legacy omission states from gateways released before
tokenDelivery, and every other explicit pair through the existing error path,
which surfaces as the page's danger callout carrying the recovery step.
* chore(plugin-sdk): refresh the API contract for the rotation result schema
Registering DeviceTokenRotateResultSchema in PluginLifecycleProtocolSchemas is
what makes the generators emit the Swift model, and that registry sits in the
type closure of 18 plugin-sdk exports, so their closureHash entries move. This
is generator output rather than a hand edit: the export set and every declaration
are unchanged, only the hashes.
* fix(ui): reject malformed device-token rotation envelopes
The parser trusted the envelope around the fields it read. A null, scalar, or
empty payload carries neither tokenDelivery nor token, so it matched the legacy
omission state and produced the reassuring completion dialog after the previous
credential had already been invalidated. A blank token did the same, though the
result schema bounds token to a non-empty string.
Only DeviceTokenRotateResultSchema's shapes are accepted now: the payload must be
a record that identifies the grant it rotated - every Gateway answering this
method returns deviceId and role, before and after tokenDelivery existed - and
token must be either absent or a non-empty string. This has to happen here
because the browser Gateway client resolves frame.payload directly, so the
registered result schema never runs on the client.
The mid-flight-reconnect reveal test asserted on a two-field stub no Gateway
sends; it now uses the real response shape.
* fix(ui): reject rotation results that do not answer the request
The rotation parser accepted any envelope naming some device and role. It
never checked scopes or rotatedAtMs, both required by
DeviceTokenRotateResultSchema, and never compared the returned grant with the
one that was requested -- so an incomplete or unrelated reply opened a success
dialog claiming a credential the operator may still hold had been replaced.
Require the full result shape and bind it to the requested grant, comparing on
the same trim normalization the device-auth store applies. The page fixture
now echoes the requested grant instead of hardcoding device-1, which is what
let a cross-device test accept a success dialog for the wrong device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ui): complete the rotation fixture the epoch test resolves
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ui): update token rotation E2E envelope
* fix(protocol): correlate rotated token delivery
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* feat(gateway): provision paired node worker installs
* docs: record node local-install PR chain
* chore(protocol): regenerate node worker manifest models
* fix(gateway): resolve node worker build through the device runtime
Repairs a rebase artifact: startup referenced a stale deviceNodeRegistry
binding; the device runtime now owns the connected-node lookup via
resolveWorkerBuild.
* feat(protocol): add portal methods and event
Bump the reviewed protocol owner-module count from 55 to 56.
* feat(gateway): add portal service and reverse proxy
* feat(agents): add portal tool
* fix(gateway): refine portal URL and proxy auth
* refactor(gateway): keep portal helper types private
* fix(gateway): declare portal transport service
* test(gateway): satisfy portal proxy lint
* test(gateway): narrow websocket payload types
* refactor(protocol): compact portal schema exports
* fix(gateway): export portal protocol types
* feat(ui): add portals page
* docs(gateway): add portals guide
* fix(gateway): dial portal targets via localhost dual-stack
Vite and other Node >=17 dev servers bind ::1 only for localhost, so a
fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost
with family autoselection and rewrite Host to match.
* fix(gateway): type portal dual-stack connection
* fix: satisfy portal integration gates
* fix(gateway): isolate portal cookie jars per target
Cookies are hostname-scoped, not port-scoped, so the per-port origin
split alone let Gateway plugin-auth cookies reach agent-run targets.
Forward only cookies carrying this portal's own name prefix (stripped),
rewrite target Set-Cookie names to the prefixed form incl. the WS 101
handshake, and drop Domain attributes.
* fix(ui): detect unreachable portals behind proxied gateways
Probe the portal origin from the browser (no-cors, 4s timeout) and show
a recovery notice with the gateway-host URL instead of a dead iframe
when only the gateway port is exposed (Serve/Funnel/reverse proxy).
Docs: cookie isolation + reachability; zh-CN glossary entry.
* test(ui): satisfy portal reachability lint
* test(gateway): provide control UI request hosts
* chore(protocol): regenerate after rebase
* fix(gateway): namespace portal auth cookies by listener
* fix(gateway): scope portal token URLs to write-capable clients
The portal bearer token rides in the summary url/tokenQuery; portal.list
is operator.read and portal.changed fans out to read subscribers, so a
read-only client could harvest an openable URL. Make those fields
optional, redact them from read-scope list responses, and drop them from
every portal.changed broadcast; write/admin clients still receive them
and the UI refetches the list on change.
* docs(web): list the portals route
* fix(gateway): type portal open credentials
* docs(gateway): clarify portals PORT/PUBLIC_URL are agent-set
Opening a portal creates only the proxy listener; the agent sets PORT
and PUBLIC_URL in its own exec command, matching the portal tool
contract. Removes the implication of an automatic env handoff.
* chore(protocol): regenerate portal models
* style(gateway): format portal method-order assertions
Rebase union-merge left the portal.list assertion wrapped; oxfmt fits it
on one line.
* chore(plugin-sdk): refresh API baseline after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* chore(protocol): refresh portal event order after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* fix(gateway): pin portal referrer policy to no-referrer
The portal URL carries its bearer token in the query, and upstream
response headers are copied verbatim, so a target answering with
Referrer-Policy: unsafe-url could leak that URL to every third-party
origin it references. Force no-referrer after the copy and drop any
inbound Referer that still carries the token before forwarding.
* fix(skills): keep ClawHub publisher identity from search through install
ClawHub search returns one entry per publisher, so several results can share a
slug. Every client collapsed the selection to that bare slug before calling
skills.detail and skills.install, and ClawHub answered 409 AMBIGUOUS_SKILL_SLUG
with no in-product way forward.
searchClawHubSkills now records the publisher-qualified reference once, on the
result that carries it, and the Gateway protocol documents it. skills.detail
parses the same reference grammar skills.install already accepted, so review and
install cannot resolve to different publishers. Control UI carries that one
reference through row actions, detail, busy state, and acknowledgement retries,
and shows it so otherwise identical rows are distinguishable.
Fixes#117633
* fix(apps): send the ClawHub publisher reference from native skill browsers
macOS, iOS, and Android read the qualified reference from search results and use
it for skills.detail, install, busy state, installed matching, and list identity,
so two publishers sharing a slug stay distinct instead of collapsing into one
ambiguous request.
* fix(skills): refuse external-source skill detail instead of reading a same-slug skill
ClawHub has no source-qualified read endpoint, so a skills-sh reference parsed
down to its bare slug would have returned a registry skill's card while install
resolved the external artifact. Review and install could name different skills.
skills.detail now fails closed on any reference that carries a source, and the
macOS and AgentPro rows show the publisher reference next to the summary instead
of only when a summary is missing, so same-slug rows stay distinguishable.
* chore(apps): refresh native i18n source baseline for the skill row references
* refactor(skills): drop the unread search-result ownerHandle field
installRef is the one reference clients send back, and no client reads the
publisher handle separately, so the protocol and Control UI carry one field
instead of two.
* fix(skills): name the next step when external skill detail is refused
Clients that gate install behind a successful review would otherwise see only a
refusal, so the error names the direct install path and the CLI equivalent.
* fix(macos): use a doc comment on the ClawHub row subtitle
swift-format's docComments rule requires doc comments on declarations; the
subtitle property carried a regular comment and failed macos-swift.
* fix(skills): carry ClawHub trust state to clients that can install
Forwarding installRef let clients install the exact publisher the operator
picked, including external skills-sh sources. It did not forward the trust
state that says ClawHub never scanned that source, so iOS AgentPro — the one
surface that installs in a single tap with no review step — could install an
unscanned artifact with nothing on screen saying so. The CLI already labels
these (docs/clawhub/cli.md, docs/cli/skills.md); native clients could not,
because trustState was never on the wire.
trustState becomes an optional field on SkillsSearchResultSchema. It is purely
additive: older clients ignore an unknown key and the field is absent for
registry results, so downgraded readers are unaffected and no protocol version
moves.
Every client that renders a search row now shows "Not scanned by ClawHub",
matching the CLI wording exactly: iOS AgentPro in the row above the install
button, macOS and Android beside the review action, and Control UI on the row
that explains why review is refused for these sources.
Covered by a wire assertion that the state reaches clients for an external
source and stays absent for registry rows, plus decode-and-label tests on the
shared Swift kit and the Android parser, and a Control UI render assertion.
* fix(ui): size the ClawHub detail dialog to a refusal message
Refusing detail for an external source made an error-only dialog reachable.
The shared preview panel reserves a tall reader height for skill documents, so
a two-line refusal rendered in a mostly empty dialog and read as broken rather
than deliberate. Found by inspecting the review captures.
* revert(ui,apps): drop the ClawHub trust label layer
Maintainer product decision: skills.sh runs its own scanners, so OpenClaw does
not add a second alert layer in the apps. Removes the label from Control UI,
iOS, macOS and Android, and drops the trustState wire field that nothing would
render. The CLI keeps its existing label; changing that is a separate call.
Publisher identity, the fail-closed detail refusal, and the message-only dialog
are unchanged. Splits the oversized skills view test file to satisfy max-lines
without a suppression.
* test(ui): fix ClawHub skill fixture checks
* chore(plugin-sdk): refresh API baseline
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* feat(gateway): add gateway-host desktop source behind desktop.host lab
Introduce the host as a first-class desktop source so operators can view
the machine OpenClaw runs on, not just cloud-worker environments:
- protocol: desktop.observe / desktop.launch with a discriminated
DesktopSource union (host | environment) plus an additive auth hint;
EnvironmentSummary gains a top-level desktop flag
- config: desktop.host { enabled, port?, passwordFile? }, Labs-gated
- rfb-probe: pure RFB version/security-type parser used to detect an
already-running loopback VNC server and classify its auth
- host-source: attaches to 127.0.0.1:<port>, refuses unauthenticated
(None) and unsupported (VeNCrypt) servers, and refuses ARD with the
supported alternative until the macOS milestone
- host-guidance: per-OS enablement text so no path dead-ends
- doctor + status report host desktop availability and auth type only
worker.desktop.observe/launch stay as delegating aliases with identical
behavior. Also drops the now-unused WorkerDesktopTunnels type export.
Live-verified against macOS Screen Sharing: probe reads RFB 003.889,
returns security types [30,33,36,35], classifies ard-account.
* test(gateway): probe RFB handshakes through the socket boundary
The probe's banner and security-offer parsers were exported solely so
unit tests could call them, which the dead-export gate rejects and which
tests internals rather than behavior. Keep them module-local and drive
the probe through a scripted loopback server instead.
The boundary tests also cover what pure-function vectors could not:
handshakes split across packets, legacy RFB 3.3 single-word security,
server-rejected handshakes, early hangups, and connect timeouts.
* feat(ui): let the Desktop panel view this machine, not just cloud workers
The Desktop panel was gated on a cloud-worker session placement, so an
operator running OpenClaw locally had no way to see the machine hosting
their main session even with a VNC server running on it.
Availability now follows the advertised desktop.observe method plus
operator.admin instead of session placement, and the picker lists every
environment whose summary reports a desktop, with the gateway row shown
as "This machine". Sources are passed to the generic desktop.observe /
desktop.launch RPCs; the app launcher stays worker-only. When a host
attach needs a password the gateway did not supply, the panel prompts and
keeps the value in memory for that connection only.
Adds the hostDesktop Labs toggle for desktop.host.enabled.
* fix(scripts): keep the env-var ratchet usable in shallow checkouts
The env-var budget check resolved its base ref, then hard-failed when
`git merge-base` found no shared ancestor. Shallow clones and grafted
agent checkouts resolve origin/main but truncate the history behind it,
so an advisory growth ratchet took down the whole check:changed gate
with "Could not resolve env-var count merge base for: origin/main".
Only the growth comparison needs a baseline, and the script already has
a no-baseline path. Treat git's exit 1 with empty output (no shared
ancestor) as that case and say so on stderr; a genuine failure still
exits 128 and still throws, and the absolute count-vs-budget check runs
either way.
* test(ui): measure the inline-code chip against its line box
The inline-code spacing test compared the chip's height to a prose text
rect, so it silently measured the monospace font's default line spacing.
That is ~17px on macOS and several px shorter on Linux, so the assertion
passed on CI and failed locally at 4.5 against a 3.75 bound -- after the
bound had already been widened once to chase browser font metrics.
Compare the chip to the paragraph's CSS line box instead, which is what
"the chip must not disrupt the line" actually means and is platform
independent. The horizontal gap stays as-is: it is em-derived padding
plus border, and it is the assertion that catches detached punctuation.
Verified both directions on macOS: the file is fully green, and
restoring the pre-fix 0.15em/0.35em padding still fails the gap
assertion at 5.41.
* feat(gateway): view macOS Screen Sharing from the Desktop panel
Modern macOS only offers ARD account authentication for Screen Sharing,
so the host desktop source refused every Mac. The Gateway now performs
the ARD handshake itself against the loopback server and hands the
browser a plain RFB 003.008 no-auth handshake, so the operator's macOS
account password authenticates the desktop without ever reaching the
browser, the observe result, a URL, or a log.
- rfb-preauth: ARD (type 30) Diffie-Hellman with MD5-derived AES-128-ECB
credentials, and VncAuth (type 2) bit-reversed DES, both under a single
10s negotiation deadline; Apple's RFB 003.889 maps to 3.8
- observe-bridge: runs pre-auth before splicing and starts the view-only
filter at clientInit, since the browser handshake is consumed here;
worker tokens keep the original version start phase
- host-source: attaches ARD, requiring per-observation credentials that
live only in the one-shot observer token and are dropped after use
- doctor: offers an explicitly confirmed sudo launchctl repair when
Screen Sharing is off, and prints the System Settings path otherwise
Live-verified against this Mac's Screen Sharing: the DH exchange and
credential framing are accepted and the server returns SecurityResult.
The VncAuth DES vector is confirmed against OpenSSL independently.
* feat(ui): commit-scoped session diff viewer with file, sync, and view menus
The Control UI session diff panel becomes a dense Amp-style viewer: per-file
menus (copy path, open file, reveal in file tree, open in editor), a Sync
Locally popover with a copyable git fetch command, view options (collapse all,
wrapping, split/unified layout), and a sticky footer that reports how far the
branch is ahead of its merge base and switches between all changes,
uncommitted work, and individual commits.
sessions.diff gains an additive scope param (all | uncommitted | commit) plus
commits, aheadCount, and mergeBase metadata. Commit-scope diffs read only the
object database, skip untracked collection, and bypass session-start baseline
filtering; unknown commits surface a typed unavailableReason. Offscreen file
bodies use content-visibility so large diffs stay responsive.
* fix(ui): satisfy session diff CI gates
* fix(gateway): fence commit-scoped session diffs to the advertised branch history
ClawSweeper found that commit-scoped sessions.diff accepted any commit resolvable in the checkout. Fence operator.read commit reads to the advertised merge-base..HEAD history and cover sibling-branch and base-history commits.
* feat(gateway): projects.list groups known checkouts by repo identity
Implements docs/plan/runners.md milestone 4 derived projects read model.
* feat(ui): regroup the Where picker by gateway, devices, and cloud
* feat(ui): placement chip shows where a session runs with reclaim
Implements docs/plan/runners.md milestone 4 placement display and reclaim.
* feat(apps): adopt the system-notice family in Apple chat (OpenClawChatUI)
Decode internal_system provenance and __openclaw history markers, classify
once into message/notice/divider rows (web-parity kinds), render minimal
hairline notice/divider SwiftUI rows, preserve metadata through history
reconciliation, live transport, and the transcript cache, and align the
transcript exporter with visible classification instead of leaking raw
[System] prompts.
* fix(apps): satisfy OpenClawKit periphery gate
Remove the dead visibleMessages projection (rows path replaced its
consumers) and annotate the provenance test-fixture initializer with the
repo-standard periphery:ignore rationale.
* fix(apps): refresh native i18n inventory after periphery cleanup