diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml
index 4e79636..ce5b1a9 100644
--- a/.github/workflows/docker-images.yml
+++ b/.github/workflows/docker-images.yml
@@ -23,6 +23,11 @@ on:
description: "Git tag (for example v0.1.2)"
required: true
type: string
+ publish-latest:
+ description: "Advance stable image aliases after a checked release"
+ required: false
+ type: boolean
+ default: false
workflow_dispatch:
inputs:
tag:
@@ -59,7 +64,7 @@ jobs:
- id: tags
env:
REQUESTED_TAG: ${{ inputs.tag || github.event.inputs.tag }}
- CALLED_FROM_RELEASE: ${{ github.event_name == 'workflow_call' }}
+ CALLED_FROM_RELEASE: ${{ inputs.publish-latest == true }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG#v}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 110f2b6..704277f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -206,6 +206,7 @@ jobs:
uses: ./.github/workflows/docker-images.yml
with:
tag: ${{ github.ref_name }}
+ publish-latest: true
verify-runtime-image:
name: "E2E: released runtime image"
diff --git a/apps/web/content/docs/guides/configuration.mdx b/apps/web/content/docs/guides/configuration.mdx
index 9841147..2dd7500 100644
--- a/apps/web/content/docs/guides/configuration.mdx
+++ b/apps/web/content/docs/guides/configuration.mdx
@@ -15,6 +15,22 @@ Both are filtered server-side to the agent's **declared, non-secret** keys — a
ignored, and a setting can never introduce a credential. Discover what a given agent declares with
`GET /agent` (`mw.agent()` / `client.Agent(ctx)`).
+## Native command access
+
+Direct workspaces use the computer account running MindWire, including root when the service was
+started as root. Commands have that account's normal access to files, installed tools and the
+network. The launcher preserves inherited PATH entries after login-shell startup; explicitly
+managed CLI versions still take precedence. Docker workspaces keep their container boundary.
+
+Codex defaults to `danger-full-access` unless a sandbox was explicitly selected. A native
+`sandbox_mode` in the user config or its selected profile, a sticky `sandbox` setting, or a per-turn
+override remains effective. Approval policy is independent. These settings apply to new and resumed
+sessions through both the CLI and app-server transports.
+
+To restrict commands, select `workspace-write` or `read-only`. Codex's `workspace-write` network
+restrictions still apply unless enabled in its native configuration. A blocked GitHub command can
+report a DNS error even when the host itself is online.
+
## Sticky config
Read and merge persisted settings. `setConfig` merges recognized keys; unknown keys are dropped.
@@ -130,12 +146,9 @@ per-turn prompt uses. A per-turn `options.systemPrompt` still wins for that one
await mw.setConfig({ systemPrompt: "You are a terse senior engineer." });
```
-
-**Codex approval mode.** Codex only carries a system prompt on its autonomous `exec` transport. With
-any `permissionMode` other than `never`, a turn carrying a system prompt (sticky or per-turn) is
-rejected with a `400` rather than silently dropped — the interactive-approval (app-server) transport
-can't take the overlay. Claude has no such constraint.
-
+Codex sends system instructions through the private app-server connection for interactive turns.
+Headless `exec` turns use a temporary native config overlay. Both honor the same prompt and approval
+settings without placing private instructions on the command line.
The sticky system prompt is one of the three persistent layers beneath a turn; the full picture —
memory files (`CLAUDE.md` / `AGENTS.md`) and saved prompt templates — is in
diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json
index 5a54cb7..ea783ef 100644
--- a/apps/web/content/docs/guides/meta.json
+++ b/apps/web/content/docs/guides/meta.json
@@ -19,6 +19,7 @@
"destinations",
"personal-computers",
"desktops",
+ "project-library",
"project-sync"
]
}
diff --git a/apps/web/content/docs/guides/project-library.mdx b/apps/web/content/docs/guides/project-library.mdx
new file mode 100644
index 0000000..e515839
--- /dev/null
+++ b/apps/web/content/docs/guides/project-library.mdx
@@ -0,0 +1,50 @@
+---
+title: Project folders & ordering
+description: Organize projects in the workspace registry, with atomic edits and offline synchronization.
+---
+
+The workspace registry stores project folders, membership, and manual order. Folders organize
+project references; moving or deleting a folder does not move files, run Git, or delete projects
+and conversations. Clients can keep their own flat or folder-based view preferences.
+
+Read `health.projectLibraryVersion` before using the library API. Version `1` supports
+`GET /workspace/project-library` and `PATCH /workspace/project-library`, plus the matching
+TypeScript and Go SDK methods.
+
+## Read and edit
+
+```ts
+const library = await mw.workspace.library();
+const folderId = crypto.randomUUID();
+const saved = await mw.workspace.editLibrary({
+ expectedRevision: library.revision,
+ folders: [{ id: folderId, name: "Client work" }],
+ placements: [{ projectId, folderId }],
+ order: [projectId],
+});
+```
+
+The project ID must belong to this workspace. The Go equivalent is
+`client.Workspace.Library()` and `client.Workspace.EditLibrary(edit)`.
+The returned library contains `version`, `revision`, `folders`, `membership`, and `order`.
+
+An edit can upsert folders, delete folder IDs, place projects into a folder or set their folder to
+`null`, and reorder selected projects or folders. Reordering a subset preserves the other entries'
+positions. The daemon validates and saves the complete edit in one transaction; invalid edits
+change nothing.
+
+## Offline clients and conflicts
+
+Cache the library and save outgoing edits durably with local UI changes. Send edits through one
+writer per workspace. The API requires the last observed `expectedRevision`; a `409` means the
+library changed and must be read again before rebasing. An identical retry is a no-op.
+
+Full workspace snapshots include `projectLibrary`. Incremental snapshots include it only when
+organization changes, so an idle client needs no additional polling. Keep disconnected workspace
+data until the daemon confirms a deletion. When a folder is deleted remotely, remove dependent
+stale edits instead of recreating that folder; its deletion is recorded in the registry.
+
+Use `importIfEmpty: true` only to migrate existing local organization into an untouched registry.
+Existing daemon organization wins. Older services can keep the client's organization local until
+upgraded. [Project switching](/docs/guides/project-sync) can retain logical membership when a project
+has copies on multiple workspaces.
diff --git a/apps/web/content/docs/guides/setup.mdx b/apps/web/content/docs/guides/setup.mdx
index 4d5bdbb..1eb22e3 100644
--- a/apps/web/content/docs/guides/setup.mdx
+++ b/apps/web/content/docs/guides/setup.mdx
@@ -57,6 +57,11 @@ configured, call [`agent()`](/docs/guides/agents#discover-an-agent) — its `ins
already satisfied is skipped, so calling it on a healthy agent is a no-op that just reports "satisfied."
`update()` is the same flow aimed at upgrading an already-installed agent.
+For a managed CLI, a failed version probe reports `compatibility: "unavailable"`; it does not
+claim the selected version changed. A real mismatch reports both versions. When
+`repairAvailable` is true, an explicit `update()` repairs the managed installation using the
+recommended supported version. Externally installed CLIs are not silently downgraded or replaced.
+
diff --git a/daemon/DESKTOP.md b/daemon/DESKTOP.md
index a3a7ee8..d8c038a 100644
--- a/daemon/DESKTOP.md
+++ b/daemon/DESKTOP.md
@@ -58,11 +58,24 @@ opening their own display connections.
The Go RFB adapter handles standard RFB 3.8 None security results, explicit BGR
true-color pixels, bounded block decoding, resize notifications and release of held
-keys/buttons. Before pointer input it requests a one-pixel update to reconcile
-display geometry. Captures request the full image and return actual PNG image
+keys/buttons. Button transitions and clicks request a one-pixel update to reconcile
+display geometry; continuing human motion reuses geometry observed in the last
+250ms. Agent actions still validate their capture. Captures request the full image and return actual PNG image
content to the harness. Repeated announcements of an unchanged size preserve the
frame and pointer revision; an actual resize invalidates the old coordinates.
+The controller also negotiates RichCursor. Changed cursor PNGs and hotspots are
+optional `cursor` fields in surface snapshots/events; the stable image ID avoids
+decoding an unchanged cursor again. This is needed because TigerVNC can render the
+cursor into a separate video viewer instead of sending it local cursor images.
+One incremental 1px request waits for changes with no idle polling. Explicit
+geometry checks/captures share that connection and take priority. Pixel coverage
+tracks partial/tiled replies; cursor-only responses cannot acknowledge a capture.
+If the server consumes a request with a cursor or partial reply, remaining pixels
+are requested again. Input is never sent by the observer. Cursor dimensions,
+hotspots and per-update allocations are bounded; empty cursor updates retain the
+last usable shape. Closing the last control/view session closes the observer too.
+
The initial SetPixelFormat must also use network byte order for channel maxima.
The pinned go-vnc dependency encodes those fields incorrectly during Connect;
`connectDesktopRFB` corrects that handshake message before sending it. Sending a
@@ -155,7 +168,11 @@ cannot take over; after revocation it needs a fresh, approved session.
Input is serialized across all clients. Each action has a stable request ID and a
durable receipt written before dispatch. Identical retries return the receipt;
-a different request with the same ID conflicts. Transport failures and a crash
+a different request with the same ID conflicts. A cancelled request is checked
+after acquiring the operation lock and immediately
+before provider dispatch. It cannot click later when the queue drains. A provider
+failure releases held input under the same lock with a separate bounded deadline.
+Transport failures and a crash
after dispatch produce `outcome_unknown`: observe the desktop before issuing any
replacement action. `dispatched` acknowledges VNC/provider delivery, not the remote
application's final state. Capture to verify the application.
diff --git a/daemon/GIT_ACCESS.md b/daemon/GIT_ACCESS.md
index a98cd19..c2c20d4 100644
--- a/daemon/GIT_ACCESS.md
+++ b/daemon/GIT_ACCESS.md
@@ -119,9 +119,17 @@ reservation until recovery and returns an observation error.
Pull only fast-forwards the current branch from origin; push sets upstream to
origin and never forces. A forwarded connection rejects another push destination.
Concurrent managed Git mutations and agent/project operations in overlapping directories
-conflict. Clients must wait for `activeOperations == 0` before replacing the
-daemon, in addition to existing run/setup/project-operation checks. Native
-terminal commands remain subject to Git's own locking.
+conflict, except that staging and unstaging can run while an agent, ordinary command,
+or terminal is active. These index-only actions still reserve the repository against
+other managed Git mutations and project synchronization/removal, and retain Git's
+native index lock. A competing managed Git operation returns HTTP 409 with a Git-busy
+message instead of the unrelated registry revision-conflict message. Other Git
+actions retain their active-work guard.
+
+Clients must wait for `activeOperations == 0` before replacing the daemon, in
+addition to existing run/setup/project-operation checks. Native terminal commands
+remain subject to Git's own locking. Observe `/workspace/git/operations/{id}/stream`
+for immediate completion; a disconnected observer can read the same durable receipt.
The TypeScript HTTP SDK exposes `workspace.git.start/operation/operations/watch/cancel`,
account settings on `workspace.git`, `turn/resolve({gitAuth})`, and
@@ -138,4 +146,7 @@ revocation, cloning and restart recovery, metadata compatibility, authenticated
run/resume lifecycle, and local fetch/pull/push with divergence protection. Git
operation tests also cover lost acknowledgements, idempotency across restart,
interrupted recovery without replay, cancellation, persistence failure, literal
-paths, unborn unstaging, and discard failures that preserve working files.
+paths, unborn unstaging, and discard failures that preserve working files. HTTP
+tests also stage and unstage real files during a live command, an idle terminal,
+and an agent turn, while verifying that competing Git operations and project
+synchronization still block the operation.
diff --git a/daemon/README.md b/daemon/README.md
index 529fa30..da374a3 100644
--- a/daemon/README.md
+++ b/daemon/README.md
@@ -30,13 +30,16 @@ curl -s -H "Authorization: Bearer $DAEMON_TOKEN" http://127.0.0.1:8790/healthz
| `STATE_PATH` | `agent-state.json` | Local JSON state file. |
| `WORKSPACE_DB_PATH` | `workspace.db` beside `STATE_PATH` | Authoritative SQLite registry for agent profiles, projects and chat links. |
| `DAEMON_TOKEN` | *(required)* | Bearer token, also saved privately beside the state file for authorized workspace clients. |
-| `MINDWIRE_ISOLATION` | `direct` | Trusted launch setting: `container` means the enclosing container provides isolation. Codex defaults to using that boundary; explicit sandbox settings and approvals are preserved. Reported by `/healthz` as `workspaceIsolation` with `workspaceIsolationVersion: 1`. |
+| `MINDWIRE_ISOLATION` | `direct` | Trusted launch setting: `direct` uses the host account's normal access; `container` uses the enclosing container as its boundary. Codex adds no inner sandbox by default. Explicit sandbox settings and approvals are preserved. Reported by `/healthz` as `workspaceIsolation` with `workspaceIsolationVersion: 1`. |
| `DEV_CORS` | off | `1` allows a cross-origin browser client (e.g. the preview app's dev server). |
The runtime image and the SDK's Docker/SSH-container launchers set `MINDWIRE_ISOLATION=container`.
-Direct host launchers leave it unset. This avoids requiring privileged nested Linux namespaces for
-Codex inside Docker; it does not disable the container boundary or change approval policy. See
-[Codex sandboxing](https://developers.openai.com/codex/sandboxing) for the native container guidance.
+Direct host launchers leave it unset. Commands run as the account that started Mindwire, with its
+normal filesystem, tools and network access (including root when started as root). Codex defaults
+to `danger-full-access` in both placements; Docker still supplies the container boundary. This
+also avoids requiring privileged nested Linux namespaces. Explicit Codex sandbox settings and
+approval policies remain effective. See [Codex sandboxing](https://learn.chatgpt.com/docs/agent-approvals-security)
+for the native controls and container guidance.
Mount only the workspace data you intend to expose to its agents. This contract requires service
0.1.17 or later; older services do not advertise `workspaceIsolationVersion`.
diff --git a/daemon/SETTINGS.md b/daemon/SETTINGS.md
index 584f562..9fa4886 100644
--- a/daemon/SETTINGS.md
+++ b/daemon/SETTINGS.md
@@ -18,6 +18,33 @@ for the selected provider. Neither credentials nor native terminal UI preference
cross this boundary. A managed Foundry connection uses its own deployment name
and provider tuning rather than an unrelated native provider's values.
+## Native command access
+
+Direct workspaces run commands as the account that started Mindwire, using its
+normal filesystem, PATH, home and network access. This includes root on a server
+started as root; Mindwire neither changes users nor adds privileges. Docker
+workspaces keep their existing container boundary.
+
+Harness launchers restore inherited PATH entries after login-shell startup and
+retain any additional login paths. An explicitly managed CLI version still takes
+precedence. This keeps commands installed through Homebrew, npm or a user tool
+directory available when a system login profile replaces PATH. Commands run by
+the harness still honor that harness's native shell settings.
+
+Codex defaults to `danger-full-access` when no sandbox has been selected. A
+native `sandbox_mode` in the user config or its selected profile, a managed
+`sandbox` setting, or a per-turn override takes precedence. The approval policy
+is independent and remains unchanged. Both app-server and exec apply this to
+new and resumed chats. Explicit `workspace-write` still uses Codex's network
+restrictions unless network access is enabled in its native configuration.
+
+To verify the installed Codex without a model account, run
+`CODEX_LOCAL=1 go test ./internal/agent/codex -run '^TestLocalNativeCommandAccess$' -v`
+from `daemon/`. A local Responses fixture requests real commands on new and
+resumed sessions through both transports. It checks the invoking UID, inherited
+tool PATH/environment, and a real HTTP connection. Codex state and credentials
+are temporary. The check also runs as root in an ordinary Linux container.
+
## Models and reasoning
Codex reads native `model/list`, including pagination and hidden-model filtering.
diff --git a/daemon/WORKSPACES.md b/daemon/WORKSPACES.md
index 43460a2..d5baf26 100644
--- a/daemon/WORKSPACES.md
+++ b/daemon/WORKSPACES.md
@@ -21,6 +21,7 @@ release architectures remain static, with CGO disabled.
| Transcripts | Harness native history, with the daemon's recorded fallback |
| Active runs, stop/reconnect state and notifications | Daemon session/run store |
| Project setup, clone progress, folder deletion, cancellation and recovery | Daemon project service; durable operations in workspace.db |
+| Library folders, folder membership and manual project order | Workspace registry; atomic revisioned library |
| List ordering by last use, collapsed UI state, navigation | Client cache |
Profiles are named harness selections. They do not duplicate harness configuration,
@@ -36,6 +37,8 @@ All routes require the daemon bearer credential. They are workspace-wide:
|---|---|
| `GET /workspace` | Full snapshot |
| `GET /workspace/changes?since=N&workspaceId=ID` | Changes newer than revision N |
+| `GET /workspace/project-library` | Read folders and manual order without scanning conversations |
+| `PATCH /workspace/project-library` | Conditionally edit folders, placements and order together |
| `POST /workspace/import` | Add missing legacy records without overwriting existing data |
| `PUT /workspace/{agents,projects,chats}/{id}` | Create or replace one record |
| `DELETE /workspace/{agents,projects,chats}/{id}?revision=N` | Remove membership at the expected record revision |
@@ -65,6 +68,34 @@ tombstones registry membership. Existing rename/fork endpoints update the regist
Registered turns use their saved profile's harness and project's directory; a
conflicting explicit harness is rejected.
+## Project library
+
+`projectLibraryVersion: 1` advertises durable folder organization. Full snapshots
+include `projectLibrary`; deltas include it only when its revision changed.
+The standalone read uses only that small record. There is no polling worker,
+filesystem scan, transcript copy, or extra database process for this feature.
+
+A library contains ordered `folders` (`id`, `name`), `membership` (project ID to
+folder ID), and `order` (project IDs). PATCH accepts `expectedRevision`, folder
+upserts, `deleteFolders`, `placements`, `order`, and `folderOrder`. A placement
+with `folderId: null` returns a project to the flat list. Reorders replace only
+the requested entries' slots, retaining other projects' relative positions.
+
+Every edit commits in one SQLite transaction with the workspace revision.
+Identical retries are no-ops. A stale differing edit returns 409; fetch the current
+library and rebase only the intended fields. Deleted folder IDs return 410 and
+cannot be recreated by a delayed rename. Folder deletion keeps all files,
+projects and chats. Project deletion removes library references in its existing
+transaction. `importIfEmpty: true` migrates a device's old local organization only
+if the daemon library has never been edited; it cannot replace another phone's
+saved organization.
+
+TypeScript exposes `workspace.library()` and `workspace.editLibrary(edit)`;
+Go exposes `Workspace.Library()` and `Workspace.EditLibrary(edit)`. Views and
+recent/name sort preferences remain local. Clients can cache organization and
+queue edits offline, but should distinguish pending edits from acknowledged
+workspace data.
+
## Project icons
`projectIconsVersion: 1` enables an optional project-relative `iconPath` in the
diff --git a/daemon/cmd/daemon/main.go b/daemon/cmd/daemon/main.go
index b1eb8e4..699d7dc 100644
--- a/daemon/cmd/daemon/main.go
+++ b/daemon/cmd/daemon/main.go
@@ -192,7 +192,7 @@ func main() {
}
health.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "agent": sup.Default(), "version": agent.Version, "historyPageVersion": api.HistoryPageVersion, "workspaceMetadataVersion": registry.Version, "projectOperationsVersion": registry.ProjectOperationsVersion, "projectIconsVersion": projecticon.Version, "projectSyncVersion": projectsync.ProtocolVersion(), "conversationBrowserVersion": conversations.BrowserVersion, "surfaceProtocolVersion": surface.Version, "localDesktopVersion": localDesktopVersion, "notificationPreferencesVersion": registry.NotificationPreferencesVersion, "gitAccessVersion": gitaccess.Version, "gitOperationsVersion": gitops.Version, "gitIdentityVersion": gitauthor.Version, "harnessPolicyVersion": toolchain.PolicyVersion, "serviceUpdateVersion": orchestrator.ServiceUpdateVersion, "workspaceIsolationVersion": agent.WorkspaceIsolationVersion, "workspaceIsolation": agent.WorkspaceIsolation(), "workspaceExecutionVersion": workspaceexec.Version, "terminalProtocolVersion": workspaceexec.TerminalVersion, "turnRequestVersion": orchestrator.TurnRequestVersion, "chatForkVersion": agent.ChatForkVersion, "imageAttachmentsVersion": agent.ImageAttachmentsVersion, "computerConnectionVersion": computerVersion})
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "agent": sup.Default(), "version": agent.Version, "historyPageVersion": api.HistoryPageVersion, "workspaceMetadataVersion": registry.Version, "projectLibraryVersion": registry.ProjectLibraryVersion, "projectOperationsVersion": registry.ProjectOperationsVersion, "projectIconsVersion": projecticon.Version, "projectSyncVersion": projectsync.ProtocolVersion(), "conversationBrowserVersion": conversations.BrowserVersion, "surfaceProtocolVersion": surface.Version, "localDesktopVersion": localDesktopVersion, "notificationPreferencesVersion": registry.NotificationPreferencesVersion, "gitAccessVersion": gitaccess.Version, "gitOperationsVersion": gitops.Version, "gitIdentityVersion": gitauthor.Version, "harnessPolicyVersion": toolchain.PolicyVersion, "serviceUpdateVersion": orchestrator.ServiceUpdateVersion, "workspaceIsolationVersion": agent.WorkspaceIsolationVersion, "workspaceIsolation": agent.WorkspaceIsolation(), "workspaceExecutionVersion": workspaceexec.Version, "terminalProtocolVersion": workspaceexec.TerminalVersion, "turnRequestVersion": orchestrator.TurnRequestVersion, "chatForkVersion": agent.ChatForkVersion, "imageAttachmentsVersion": agent.ImageAttachmentsVersion, "computerConnectionVersion": computerVersion})
})
root.Handle("/healthz", api.Auth(token, health))
diff --git a/daemon/internal/agent/codex/appserver.go b/daemon/internal/agent/codex/appserver.go
index 1a3cd17..6573d03 100644
--- a/daemon/internal/agent/codex/appserver.go
+++ b/daemon/internal/agent/codex/appserver.go
@@ -89,7 +89,9 @@ func (a appServer) Run(ctx context.Context, in agent.TurnInput, emit agent.Emit)
if err != nil {
return agent.TurnResult{Text: err.Error(), IsError: true}, err
}
- cmd := exec.CommandContext(ctx, "bash", "-lc", toolchain.Shell(a.command))
+ commandCtx, stopCommand := context.WithCancel(ctx)
+ defer stopCommand()
+ cmd := exec.CommandContext(commandCtx, "bash", "-lc", toolchain.Shell(a.command))
proc.Group(cmd) // cancel/interrupt kills the whole app-server tree, not just the bash parent
cmd.Env = toolchain.Environment()
for k, v := range a.env {
@@ -113,6 +115,12 @@ func (a appServer) Run(ctx context.Context, in agent.TurnInput, emit agent.Emit)
result, got := a.converse(ctx, stdin, stdout, in.Inbound, emit)
_ = stdin.Close() // signal the server we're done so it exits and stdout hits EOF
+ // A rejected resume (for example, a thread owned by another Codex client) can
+ // leave app-server alive after EOF. This process belongs only to this turn;
+ // reap it before waiting so the run always becomes terminal and Stop stays usable.
+ if !got || result.IsError || result.Cancelled || ctx.Err() != nil {
+ stopCommand()
+ }
werr := cmd.Wait()
if !got {
@@ -177,9 +185,21 @@ func (e *rpcErr) text() string {
if detail := errorText(e.Data, ""); detail != "" && !strings.Contains(message, detail) {
message = strings.TrimSpace(message + "\n" + detail)
}
+ if conflict := sessionConflictText(message); conflict != "" {
+ return conflict
+ }
return fmt.Sprintf("Codex RPC error %d: %s", e.Code, message)
}
+func sessionConflictText(message string) string {
+ lower := strings.ToLower(message)
+ if (strings.Contains(lower, "thread") || strings.Contains(lower, "session") || strings.Contains(lower, "conversation")) &&
+ (strings.Contains(lower, "already in use") || strings.Contains(lower, "another client") || strings.Contains(lower, "another process")) {
+ return "This Codex conversation is open in another client. Close it there, then try sending again."
+ }
+ return ""
+}
+
type pendingResp struct {
result json.RawMessage
err *rpcErr
@@ -581,7 +601,19 @@ func (a appServer) converse(ctx context.Context, w io.Writer, r io.Reader, inbou
if p.WillRetry {
emit(agent.Event{Type: agent.EventStatus, Meta: map[string]any{"message": lastError, "willRetry": true}})
} else {
- emit(agent.Event{Type: agent.EventError, Error: lastError})
+ if conflict := sessionConflictText(lastError); conflict != "" {
+ lastError = conflict
+ emit(agent.Event{Type: agent.EventError, Error: lastError})
+ terminated = true
+ // Session ownership errors need no turn/completed event; Codex
+ // may never have accepted a turn in the first place.
+ smu.Lock()
+ sid := sessionID
+ smu.Unlock()
+ emitTerminal(agent.TurnResult{Text: conflict, IsError: true, SessionID: sid}, tokenMeta())
+ } else {
+ emit(agent.Event{Type: agent.EventError, Error: lastError})
+ }
}
}
@@ -633,6 +665,12 @@ func (a appServer) converse(ctx context.Context, w io.Writer, r io.Reader, inbou
select {
case resp = <-ch:
default:
+ smu.Lock()
+ terminal := result
+ smu.Unlock()
+ if terminal.IsError && terminal.Text != "" {
+ return nil, errors.New(terminal.Text)
+ }
return nil, errTransportClosed
}
case resp = <-ch:
diff --git a/daemon/internal/agent/codex/codex.go b/daemon/internal/agent/codex/codex.go
index 8b0facb..3c543be 100644
--- a/daemon/internal/agent/codex/codex.go
+++ b/daemon/internal/agent/codex/codex.go
@@ -140,7 +140,7 @@ var codexSpecs = []fieldSpec{
{key: keyApproval, label: "Approval policy", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeUnified, canon: agent.CanonPermissionMode, src: srcApproval, emptyLabel: "Never (autonomous)", help: "When Codex pauses to ask you before running a command."},
{key: keyReviewer, label: "Approval reviewer", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeUnified, canon: agent.CanonApprovalReviewer, src: srcReviewer, help: "Approve for me uses Codex's native reviewer with Ask when needed. Ask before commands uses manual review. Custom connections use the chat model for automatic review."},
{key: keyCollaboration, label: "Mode", section: "Model & reasoning", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keyCollaboration, src: srcCollaboration, help: "Planning mode asks questions and prepares a plan before implementation. Applies on the next turn."},
- {key: keySandbox, label: "Sandbox", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keySandbox, src: srcSandbox, emptyLabel: "Default (workspace-write)", help: "Filesystem/network isolation for model-run commands — Codex's second permission axis, orthogonal to the approval policy."},
+ {key: keySandbox, label: "Sandbox", section: "Permissions & sandbox", typ: agent.FieldSelect, scope: agent.ScopeCustom, canon: keySandbox, src: srcSandbox, emptyLabel: "Default (host access)", help: "Commands use this computer's files, tools and network as the account running Mindwire. Choose a sandbox to restrict access. Approval policy controls when Codex asks you."},
{key: keyWorkdir, label: "Working directory", section: "Workspace", typ: agent.FieldText, scope: agent.ScopeCustom, canon: keyWorkdir, src: srcNone, placeholder: "/path/to/repo", help: "Directory the agent uses as its working root. Applies to fresh sessions; a resumed session keeps its original directory."},
{key: keyAddDir, label: "Extra directory", section: "Workspace", typ: agent.FieldText, scope: agent.ScopeUnified, canon: agent.CanonExtraDirs, src: srcNone, placeholder: "/path/to/other", help: "Additional directory that should be writable alongside the primary workspace."},
@@ -305,16 +305,16 @@ func approvalPolicy(in agent.TurnInput) string {
return "never"
}
-// sandbox preserves an explicit user choice. Otherwise an externally isolated
-// container is the sandbox boundary; direct placements retain workspace-write.
+// sandbox preserves explicit native/profile/turn settings. Otherwise commands
+// run with the daemon account's normal access. Direct workspaces use the host;
+// container workspaces already have an outer filesystem/process boundary.
+// workspace-write would silently block network access (including DNS/gh), and
+// require nested Linux namespaces on some hosts. Approval policy is independent.
func sandbox(in agent.TurnInput) string {
if v := strings.TrimSpace(in.Config[keySandbox]); v != "" {
return v
}
- if agent.WorkspaceIsolation() == "container" {
- return "danger-full-access"
- }
- return "workspace-write"
+ return "danger-full-access"
}
// materialized holds per-turn temp-file paths and resolved attachment references the adapter creates
@@ -391,7 +391,7 @@ func buildExecCommand(in agent.TurnInput, files materialized) string {
// flag, so it's a config override on both fresh and resume.
cli += " -c " + agent.ShellQuote("approval_policy="+approvalPolicy(in))
- // Sandbox posture — always emitted (default workspace-write). Fresh takes -s; resume rejects it, so
+ // Sandbox posture — always emitted (default native access). Fresh takes -s; resume rejects it, so
// it goes through a config override.
sb := sandbox(in)
if resuming {
diff --git a/daemon/internal/agent/codex/command_test.go b/daemon/internal/agent/codex/command_test.go
index 6911cf0..3bd3e1c 100644
--- a/daemon/internal/agent/codex/command_test.go
+++ b/daemon/internal/agent/codex/command_test.go
@@ -128,15 +128,15 @@ func TestBuildExecCommandSessionPrecedence(t *testing.T) {
}
}
-// Defaults: an unconfigured turn is autonomous — approval never, sandbox workspace-write — and unset
+// Defaults: an unconfigured turn is autonomous with native host/container access, and unset
// settings never appear as flags.
func TestBuildExecCommandDefaultsAndOmits(t *testing.T) {
cmd := buildExecCommand(agent.TurnInput{Message: "x"}, materialized{})
if !strings.Contains(cmd, "-c 'approval_policy=never'") {
t.Errorf("expected default approval_policy=never, got: %s", cmd)
}
- if !strings.Contains(cmd, "-s 'workspace-write'") {
- t.Errorf("expected default sandbox workspace-write, got: %s", cmd)
+ if !strings.Contains(cmd, "-s 'danger-full-access'") {
+ t.Errorf("expected default native access, got: %s", cmd)
}
for _, bad := range []string{"-m ", "-C ", "--add-dir", "model_reasoning_effort", "resume"} {
if strings.Contains(cmd, bad) {
diff --git a/daemon/internal/agent/codex/isolation_test.go b/daemon/internal/agent/codex/isolation_test.go
index 25da5cd..f0edcda 100644
--- a/daemon/internal/agent/codex/isolation_test.go
+++ b/daemon/internal/agent/codex/isolation_test.go
@@ -7,14 +7,11 @@ import (
"github.com/oblien/mindwire/daemon/internal/agent"
)
-func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) {
+func TestNativeAccessPreservesWorkspaceBoundaryAndExplicitPermissions(t *testing.T) {
for _, placement := range []string{"", "direct", "container", "unknown"} {
t.Run(placement, func(t *testing.T) {
t.Setenv("MINDWIRE_ISOLATION", placement)
- want := "workspace-write"
- if placement == "container" {
- want = "danger-full-access"
- }
+ want := "danger-full-access"
for _, sessionID := range []string{"", "existing-thread"} {
input := agent.TurnInput{Message: "continue", SessionID: sessionID, Config: map[string]string{keyApproval: "on-request"},
Env: map[string]string{"MINDWIRE_ISOLATION": "container"}}
@@ -22,6 +19,11 @@ func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) {
if server.sandbox != want || server.approval != "on-request" {
t.Fatalf("placement changed approvals or ignored sandbox: %s, %s", server.sandbox, server.approval)
}
+ for _, params := range []map[string]any{server.startParams(), server.resumeParams()} {
+ if params["sandbox"] != want || params["approvalPolicy"] != "on-request" {
+ t.Fatalf("thread start/resume lost native access or approvals: %+v", params)
+ }
+ }
flag := "-s '" + want + "'"
if sessionID != "" {
flag = "-c 'sandbox_mode=" + want + "'"
@@ -30,9 +32,11 @@ func TestPlacementControlsOnlyDefaultSandbox(t *testing.T) {
!strings.Contains(command, "approval_policy=on-request") {
t.Fatalf("exec/resume must agree with app-server: %s", command)
}
- input.Config[keySandbox] = "read-only"
- if sandbox(input) != "read-only" {
- t.Fatal("placement replaced an explicit user restriction")
+ for _, explicit := range []string{"read-only", "workspace-write"} {
+ input.Config[keySandbox] = explicit
+ if sandbox(input) != explicit || newAppServer(input, materialized{}).sandbox != explicit {
+ t.Fatal("placement replaced an explicit user restriction")
+ }
}
}
})
diff --git a/daemon/internal/agent/codex/native_access_local_test.go b/daemon/internal/agent/codex/native_access_local_test.go
new file mode 100644
index 0000000..23522ad
--- /dev/null
+++ b/daemon/internal/agent/codex/native_access_local_test.go
@@ -0,0 +1,164 @@
+package codex
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/oblien/mindwire/daemon/internal/agent"
+)
+
+// The installed CLI executes real shell tools against a local model fixture.
+// This verifies PATH, user identity, environment and network on start AND resume,
+// without inference, real credentials, or changes to the user's Codex home.
+// Also runnable as root in Linux to catch accidental nested sandbox requirements.
+func TestLocalNativeCommandAccess(t *testing.T) {
+ if os.Getenv("CODEX_LOCAL") != "1" {
+ t.Skip("set CODEX_LOCAL=1 to test native commands with the installed Codex")
+ }
+ for _, binary := range []string{"codex", "curl"} {
+ if _, err := exec.LookPath(binary); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("MINDWIRE_ISOLATION", "direct")
+ t.Setenv("MINDWIRE_TOOLCHAIN_DIR", t.TempDir())
+ bin := filepath.Join(t.TempDir(), "native tools")
+ if err := os.Mkdir(bin, 0700); err != nil {
+ t.Fatal(err)
+ }
+ fixture := "#!/bin/sh\nprintf 'native-user=%s native-env=%s\\n' \"$(id -u)\" \"$MINDWIRE_NATIVE_CHECK\"\n"
+ if err := os.WriteFile(filepath.Join(bin, "mindwire-native-probe"), []byte(fixture), 0700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+
+ for _, streaming := range []bool{false, true} {
+ t.Run(fmt.Sprintf("app-server=%t", streaming), func(t *testing.T) {
+ codexDir, cwd := t.TempDir(), t.TempDir()
+ t.Setenv("CODEX_HOME", codexDir)
+ var requests, probes atomic.Int32
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "GET" && r.URL.Path == "/native-probe" {
+ probes.Add(1)
+ _, _ = fmt.Fprintln(w, "native-network-ok")
+ return
+ }
+ if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/responses") {
+ http.NotFound(w, r)
+ return
+ }
+ var request map[string]any
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
+ t.Error(err)
+ w.WriteHeader(400)
+ return
+ }
+ index := int(requests.Add(1))
+ if index%2 == 0 {
+ // Inspect only this call's result, not the previous turn's history.
+ callID := fmt.Sprintf("call_desktop_%d", index-1)
+ output := localCommandOutput(request["input"], callID)
+ for _, want := range []string{fmt.Sprintf("native-user=%d native-env=inherited", os.Geteuid()), "native-network-ok"} {
+ if !strings.Contains(output, want) {
+ t.Errorf("native command output missing %q: %s", want, output)
+ }
+ }
+ writeLocalReply(w, index, "Native checks complete.")
+ return
+ }
+ name, namespace := localCommandTool(request["tools"], "")
+ command := "mindwire-native-probe && curl --fail --silent --show-error --connect-timeout 2 --max-time 4 " + agent.ShellQuote(server.URL+"/native-probe")
+ // Verify the environment arriving at the harness. A tool can opt into
+ // another login shell, whose own profile may intentionally replace PATH.
+ args := map[string]any{"workdir": cwd, "login": false}
+ switch name {
+ case "exec_command":
+ args["cmd"], args["yield_time_ms"] = command, 1000
+ case "shell_command":
+ args["command"], args["timeout_ms"] = command, 10000
+ case "shell":
+ args["command"], args["timeout_ms"] = []string{"bash", "-c", command}, 10000
+ default:
+ t.Error("native CLI did not advertise a command tool")
+ writeLocalReply(w, index, "Missing shell.")
+ return
+ }
+ writeLocalToolCall(w, index, namespace, name, args)
+ }))
+ defer server.Close()
+ config := fmt.Sprintf("model = \"gpt-5.5\"\nmodel_provider = \"local\"\n[model_providers.local]\nname = \"Local native-access fixture\"\nbase_url = %q\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n", server.URL)
+ if err := os.WriteFile(filepath.Join(codexDir, "config.toml"), []byte(config), 0600); err != nil {
+ t.Fatal(err)
+ }
+ in := agent.TurnInput{Message: "Run the native command check.", CWD: cwd,
+ Config: map[string]string{keyModel: "gpt-5.5", keyEffort: "low"},
+ Env: map[string]string{"CODEX_API_KEY": "fixture-api-key", "OPENAI_BASE_URL": server.URL, "MINDWIRE_NATIVE_CHECK": "inherited"}}
+ if streaming {
+ in.Inbound = make(chan agent.Inbound)
+ }
+ for turn := 1; turn <= 2; turn++ {
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
+ result, err := (adapter{}).RunStream(ctx, in, func(agent.Event) {})
+ cancel()
+ if err != nil || result.IsError || result.Text != "Native checks complete." {
+ t.Fatalf("turn %d: %+v %v", turn, result, err)
+ }
+ if result.SessionID == "" || (in.SessionID != "" && result.SessionID != in.SessionID) {
+ t.Fatal("native check did not resume the same conversation")
+ }
+ in.SessionID = result.SessionID
+ }
+ if requests.Load() != 4 || probes.Load() != 2 {
+ t.Fatalf("start/resume requests=%d network probes=%d", requests.Load(), probes.Load())
+ }
+ })
+ }
+}
+
+func localCommandTool(value any, namespace string) (string, string) {
+ switch value := value.(type) {
+ case map[string]any:
+ name, _ := value["name"].(string)
+ if value["type"] == "namespace" {
+ namespace = name
+ }
+ if name == "exec_command" || name == "shell_command" || name == "shell" {
+ return name, namespace
+ }
+ for _, child := range value {
+ if name, scope := localCommandTool(child, namespace); name != "" {
+ return name, scope
+ }
+ }
+ case []any:
+ for _, child := range value {
+ if name, scope := localCommandTool(child, namespace); name != "" {
+ return name, scope
+ }
+ }
+ }
+ return "", ""
+}
+
+func localCommandOutput(value any, callID string) string {
+ items, _ := value.([]any)
+ for _, raw := range items {
+ item, _ := raw.(map[string]any)
+ if item["type"] == "function_call_output" && item["call_id"] == callID {
+ output, _ := json.Marshal(item["output"])
+ return string(output)
+ }
+ }
+ return ""
+}
diff --git a/daemon/internal/agent/codex/resume_process_test.go b/daemon/internal/agent/codex/resume_process_test.go
new file mode 100644
index 0000000..24b58fa
--- /dev/null
+++ b/daemon/internal/agent/codex/resume_process_test.go
@@ -0,0 +1,61 @@
+package codex
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/oblien/mindwire/daemon/internal/agent"
+)
+
+func TestRejectedResumeReapsAppServerWithoutWaitingForTurnTimeout(t *testing.T) {
+ for _, mode := range []string{"rpc", "notification"} {
+ t.Run(mode, func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
+ defer cancel()
+ a := appServer{command: "'" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "' -test.run=^TestRejectedResumeHelper$",
+ env: map[string]string{"MINDWIRE_REJECTED_RESUME_HELPER": mode}, resumeID: "busy-thread"}
+ result, _ := a.Run(ctx, agent.TurnInput{}, func(agent.Event) {})
+ if ctx.Err() != nil {
+ t.Fatal("resume rejection left the process and run stuck until timeout")
+ }
+ if !result.IsError || !strings.Contains(result.Text, "open in another client") {
+ t.Fatalf("lost native conflict: %+v", result)
+ }
+ })
+ }
+}
+
+func TestRejectedResumeHelper(t *testing.T) {
+ mode := os.Getenv("MINDWIRE_REJECTED_RESUME_HELPER")
+ if mode == "" {
+ return
+ }
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ var request rpcIn
+ _ = json.Unmarshal(scanner.Bytes(), &request)
+ if request.Method == "initialize" {
+ fmt.Printf("{\"id\":%s,\"result\":{}}\n", request.ID)
+ }
+ if request.Method == "thread/resume" {
+ if mode == "rpc" {
+ fmt.Printf("{\"id\":%s,\"error\":{\"code\":-32000,\"message\":\"thread is already in use by another client\"}}\n", request.ID)
+ } else {
+ fmt.Printf("{\"id\":%s,\"result\":{\"thread\":{\"id\":\"busy-thread\"}}}\n", request.ID)
+ }
+ }
+ if request.Method == "turn/start" && mode == "notification" {
+ fmt.Println(`{"method":"error","params":{"threadId":"busy-thread","error":{"message":"session is already in use by another process"},"willRetry":false}}`)
+ }
+ }
+ // Mimic an app-server which keeps runtime tasks alive after stdin closes.
+ for {
+ time.Sleep(time.Hour)
+ }
+}
diff --git a/daemon/internal/agent/codex/settings_test.go b/daemon/internal/agent/codex/settings_test.go
index 4f98dea..d397aec 100644
--- a/daemon/internal/agent/codex/settings_test.go
+++ b/daemon/internal/agent/codex/settings_test.go
@@ -78,9 +78,13 @@ model_provider = "azure_custom"
model_reasoning_effort = "max"
model_reasoning_summary = "auto"
approvals_reviewer = "user"
+sandbox_mode = "read-only"
+approval_policy = "untrusted"
profile = "focused"
[profiles.focused]
model_reasoning_summary = "concise"
+sandbox_mode = "workspace-write"
+approval_policy = "on-request"
[model_providers.azure_custom]
name = "Private provider"
experimental_bearer_token = "fixture-secret"
@@ -95,7 +99,7 @@ stream_idle_timeout_ms = 300000
}
store := mapStore{}
values := agent.ReadSettings(adapter{}, store)
- for key, want := range map[string]string{keyModel: "gpt-6-astra", keyEffort: "max", keySummary: "concise", keyReviewer: "user", keyRequestRetries: "4", keyStreamRetries: "10", keyStreamTimeout: "300000"} {
+ for key, want := range map[string]string{keyModel: "gpt-6-astra", keyEffort: "max", keySummary: "concise", keyReviewer: "user", keySandbox: "workspace-write", keyApproval: "on-request", keyRequestRetries: "4", keyStreamRetries: "10", keyStreamTimeout: "300000"} {
if values[key] != want {
t.Errorf("%s = %q; want %q", key, values[key], want)
}
@@ -104,6 +108,18 @@ stream_idle_timeout_ms = 300000
if strings.Contains(string(encoded), "fixture-secret") || strings.Contains(string(encoded), "tui") {
t.Fatal("non-settings metadata or credentials leaked")
}
+ server := newAppServer(agent.TurnInput{Config: values}, materialized{})
+ if server.sandbox != "workspace-write" || server.approval != "on-request" {
+ t.Fatal("native profile restrictions were replaced by the host-access default")
+ }
+ store[keySandbox] = "read-only"
+ if sandbox(agent.TurnInput{Config: agent.ReadSettings(adapter{}, store)}) != "read-only" {
+ t.Fatal("explicit managed sandbox was lost")
+ }
+ store[keySandbox] = ""
+ if sandbox(agent.TurnInput{Config: agent.ReadSettings(adapter{}, store)}) != "workspace-write" {
+ t.Fatal("reset did not restore the native sandbox")
+ }
store[keyEffort] = "high"
if agent.ReadSettings(adapter{}, store)[keyEffort] != "high" {
t.Fatal("managed override lost")
diff --git a/daemon/internal/api/api.go b/daemon/internal/api/api.go
index 91c5756..29fc773 100644
--- a/daemon/internal/api/api.go
+++ b/daemon/internal/api/api.go
@@ -139,6 +139,8 @@ type Route struct {
// Routes is the full authenticated API surface. Every agent-specific route accepts ?agent=.
func (a *API) Routes() []Route {
return []Route{
+ {"GET", "/workspace/project-library", a.projectLibrary},
+ {"PATCH", "/workspace/project-library", a.projectLibrary},
{"GET", "/workspace/host", a.workspaceHost},
{"GET", "/workspace/resources", a.workspaceResources},
{"GET", "/workspace/files", a.workspaceFiles},
diff --git a/daemon/internal/api/git.go b/daemon/internal/api/git.go
index 8fbfdd8..8f05d07 100644
--- a/daemon/internal/api/git.go
+++ b/daemon/internal/api/git.go
@@ -259,6 +259,13 @@ type gitOperationRequest struct {
Auth *gitaccess.Auth `json:"auth,omitempty"`
}
+// Admission conflicts are not registry revision conflicts. Keep the HTTP 409
+// classification, but tell the caller which work actually needs to finish.
+type gitBusyError string
+
+func (e gitBusyError) Error() string { return string(e) }
+func (e gitBusyError) Unwrap() error { return registry.ErrConflict }
+
func (a *API) startGitOperation(ctx context.Context, projectID string, req gitOperationRequest) (registry.GitOperation, error) {
spec := registry.GitSpec{ID: req.ID, ProjectID: projectID, Action: req.Action, Paths: req.Paths, Message: req.Message,
Branch: req.Branch, Remote: req.Remote, NewName: req.NewName, ExpectedTip: req.ExpectedTip, Force: req.Force, Identity: req.Identity,
@@ -284,8 +291,16 @@ func (a *API) startGitOperation(ctx context.Context, projectID string, req gitOp
if err != nil {
return registry.GitOperation{}, err
}
- if a.BusyPath(spec.Path) {
- return registry.GitOperation{}, registry.ErrConflict
+ if a.gitBusyPath(spec.Path) {
+ return registry.GitOperation{}, gitBusyError("Another Git operation is running in this repository. Wait for it to finish and try again.")
+ }
+ // Index-only actions can run while an agent edits files or a terminal is
+ // open. Git's own index lock still excludes competing native Git writes.
+ // Worktree/history mutations retain the active-work guard; every action also
+ // retains the durable Git/project-sync reservation below.
+ indexOnly := spec.Action == "stage" || spec.Action == "unstage"
+ if !indexOnly && a.BusyPath(spec.Path) {
+ return registry.GitOperation{}, gitBusyError("Close active terminals or wait for the agent or command to finish before running this Git operation.")
}
if err := a.registry.CheckProjectPath(spec.Path); err != nil {
return registry.GitOperation{}, err
diff --git a/daemon/internal/api/git_admission_test.go b/daemon/internal/api/git_admission_test.go
new file mode 100644
index 0000000..f467a56
--- /dev/null
+++ b/daemon/internal/api/git_admission_test.go
@@ -0,0 +1,140 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/oblien/mindwire/daemon/internal/agent"
+ "github.com/oblien/mindwire/daemon/internal/orchestrator"
+ "github.com/oblien/mindwire/daemon/internal/registry"
+ "github.com/oblien/mindwire/daemon/internal/workspaceexec"
+)
+
+type gitWorkingAdapter struct {
+ resolveFakeAdapter
+ started chan struct{}
+}
+
+func (f *gitWorkingAdapter) RunStream(ctx context.Context, _ agent.TurnInput, _ agent.Emit) (agent.TurnResult, error) {
+ close(f.started)
+ <-ctx.Done()
+ return agent.TurnResult{}, ctx.Err()
+}
+
+func TestGitHTTPStageAndUnstageDuringOrdinaryWork(t *testing.T) {
+ for _, work := range []string{"command", "terminal", "agent"} {
+ t.Run(work, func(t *testing.T) {
+ fake := &gitWorkingAdapter{resolveFakeAdapter: resolveFakeAdapter{id: "git-stage-active-agent"}, started: make(chan struct{})}
+ if work == "agent" {
+ agent.Register(fake)
+ }
+ h, a, dir := gitHTTPFixture(t)
+ path := filepath.Join(dir, "tracked")
+ if err := os.WriteFile(path, []byte("base\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ apiGit(t, "-C", dir, "add", "tracked")
+ apiGit(t, "-C", dir, "-c", "user.email=fixture@example.invalid", "commit", "-m", "Base")
+ if err := os.WriteFile(path, []byte("working changes\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ switch work {
+ case "command":
+ ctx, cancel := context.WithCancel(context.Background())
+ started, done := make(chan struct{}, 1), make(chan struct{})
+ go func() {
+ defer close(done)
+ _, _ = a.execution.Exec(ctx, workspaceexec.ExecRequest{
+ Argv: []string{"sh", "-c", "printf ready; exec sleep 60"}, Directory: dir,
+ }, func(_ string, _ []byte) {
+ select {
+ case started <- struct{}{}:
+ default:
+ }
+ })
+ }()
+ t.Cleanup(func() { cancel(); <-done })
+ select {
+ case <-started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("command did not start")
+ }
+ case "terminal":
+ t.Setenv("SHELL", "/bin/sh")
+ terminal, err := a.execution.Terminals.Open(workspaceexec.TerminalRequest{
+ ID: "git-stage-terminal", Directory: dir, Columns: 80, Rows: 24,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = a.execution.Terminals.Remove(terminal.ID) })
+ case "agent":
+ adapter, ok := a.sup.Resolve(fake.ID())
+ if !ok {
+ t.Fatal("could not resolve test adapter")
+ }
+ run, err := a.sup.StartTurnChecked(adapter, orchestrator.StartTurnInput{ChatID: "chat", Message: "Work", CWD: dir})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.sup.Cancel(run.ID); a.sup.Wait() })
+ select {
+ case <-fake.started:
+ case <-time.After(5 * time.Second):
+ t.Fatal("agent did not start")
+ }
+ }
+ if !a.BusyPath(dir) {
+ t.Fatal("fixture has no active work")
+ }
+ for _, action := range []string{"stage", "unstage"} {
+ body := `{"id":"` + action + `","action":"` + action + `","paths":["tracked"]}`
+ got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body)
+ var operation registry.GitOperation
+ if got.Code != 202 || json.Unmarshal(got.Body.Bytes(), &operation) != nil {
+ t.Fatalf("%s during %s: %d %s", action, work, got.Code, got.Body.String())
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ result, err := a.gitJobs.Wait(ctx, operation.ID)
+ cancel()
+ if err != nil || result.Status != "succeeded" {
+ t.Fatalf("%s result: %+v %v", action, result, err)
+ }
+ staged := apiGit(t, "-C", dir, "diff", "--cached", "--name-only")
+ if (action == "stage" && staged != "tracked") || (action == "unstage" && staged != "") {
+ t.Fatalf("%s index: %q", action, staged)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || string(data) != "working changes\n" {
+ t.Fatalf("%s changed working files: %q %v", action, data, err)
+ }
+ if !a.BusyPath(dir) {
+ t.Fatalf("%s interrupted active work", action)
+ }
+ }
+ got := serve(t, h, "POST", "/workspace/projects/project/git/operations", `{"id":"discard","action":"discard","paths":["tracked"]}`)
+ if got.Code != 409 || strings.Contains(got.Body.String(), "record changed") {
+ t.Fatalf("discard must retain a clear active-work guard: %d %s", got.Code, got.Body.String())
+ }
+ })
+ }
+}
+
+func TestGitHTTPIndexActionsRespectProjectSyncReservation(t *testing.T) {
+ h, a, dir := gitHTTPFixture(t)
+ if err := a.registry.ReserveSync("sync", dir); err != nil {
+ t.Fatal(err)
+ }
+ for _, action := range []string{"stage", "unstage"} {
+ body := `{"id":"` + action + `","action":"` + action + `","paths":["tracked"]}`
+ got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body)
+ if got.Code != 409 || !strings.Contains(got.Body.String(), "synchronization") {
+ t.Fatalf("%s during sync: %d %s", action, got.Code, got.Body.String())
+ }
+ }
+}
diff --git a/daemon/internal/api/git_test.go b/daemon/internal/api/git_test.go
index 347b7dd..c955622 100644
--- a/daemon/internal/api/git_test.go
+++ b/daemon/internal/api/git_test.go
@@ -195,6 +195,8 @@ func TestGitHTTPDurableMutationsAndRecovery(t *testing.T) {
body := `{"id":"competing","action":"` + action + `","paths":["tracked"]}`
if got := serve(t, h, "POST", "/workspace/projects/project/git/operations", body); got.Code != 409 {
t.Fatalf("%s bypassed coordination: %d %s", action, got.Code, got.Body.String())
+ } else if strings.Contains(got.Body.String(), "record changed") || !strings.Contains(got.Body.String(), "Git operation") {
+ t.Fatalf("%s has a misleading conflict: %s", action, got.Body.String())
}
}
if got := serve(t, h, "GET", "/workspace/projects/project/git/operations?active=true", ""); got.Code != 200 || !strings.Contains(got.Body.String(), "blocked-commit") {
diff --git a/daemon/internal/api/project_library.go b/daemon/internal/api/project_library.go
new file mode 100644
index 0000000..bdc8fb7
--- /dev/null
+++ b/daemon/internal/api/project_library.go
@@ -0,0 +1,33 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/oblien/mindwire/daemon/internal/registry"
+)
+
+func (a *API) projectLibrary(w http.ResponseWriter, r *http.Request) {
+ if !a.requireRegistry(w) {
+ return
+ }
+ a.registryMu.Lock()
+ defer a.registryMu.Unlock()
+ if r.Method == http.MethodPatch {
+ var edit registry.ProjectLibraryEdit
+ if err := decode(w, r, &edit); err != nil {
+ badRequest(w, "invalid project library edit")
+ return
+ }
+ if err := a.registry.EditProjectLibrary(edit); err != nil {
+ workspaceError(w, err)
+ return
+ }
+ }
+ // No native session discovery or filesystem reads for library-only operations.
+ library, err := a.registry.ProjectLibrary()
+ if err != nil {
+ workspaceError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, library)
+}
diff --git a/daemon/internal/api/project_library_test.go b/daemon/internal/api/project_library_test.go
new file mode 100644
index 0000000..d4c8aa3
--- /dev/null
+++ b/daemon/internal/api/project_library_test.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+
+ "github.com/oblien/mindwire/daemon/internal/registry"
+)
+
+func TestProjectLibraryHTTPAuthenticatedTwoClients(t *testing.T) {
+ h, _, a := newRegistryAPIEnv(t)
+ for _, method := range []string{"GET", "PATCH"} {
+ if r := serve(t, h, method, "/workspace/project-library", "{}"); r.Code != 401 {
+ t.Fatalf("unauthenticated %s: %d", method, r.Code)
+ }
+ }
+ authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Header.Set("Authorization", "Bearer workspace-secret")
+ h.ServeHTTP(w, r)
+ })
+ if err := a.registry.Import(registry.Import{Projects: []registry.Project{{Record: registry.Record{ID: "p"}, Name: "App", Path: "/work/app"}}}); err != nil {
+ t.Fatal(err)
+ }
+ body := `{"expectedRevision":0,"folders":[{"id":"work","name":"Work"}],"placements":[{"projectId":"p","folderId":"work"}],"order":["p"]}`
+ first := serve(t, authorized, "PATCH", "/workspace/project-library", body)
+ if first.Code != 200 {
+ t.Fatal(first.Code, first.Body.String())
+ }
+ var saved registry.ProjectLibrary
+ if err := json.Unmarshal(first.Body.Bytes(), &saved); err != nil {
+ t.Fatal(err)
+ }
+ second := serve(t, authorized, "GET", "/workspace/project-library?agent=claude-code", "")
+ if second.Code != 200 || second.Body.String() != first.Body.String() {
+ t.Fatal("a second client/harness did not see the shared folder")
+ }
+ if repeat := serve(t, authorized, "PATCH", "/workspace/project-library", body); repeat.Body.String() != first.Body.String() {
+ t.Fatal("retry duplicated an edit")
+ }
+ if conflict := serve(t, authorized, "PATCH", "/workspace/project-library", `{"expectedRevision":0,"folders":[{"id":"work","name":"Stale"}]}`); conflict.Code != 409 {
+ t.Fatal("stale client overwrote folder", conflict.Code)
+ }
+ cleared := serve(t, authorized, "PATCH", "/workspace/project-library", fmt.Sprintf(`{"expectedRevision":%d,"placements":[{"projectId":"p","folderId":null}]}`, saved.Revision))
+ if cleared.Code != 200 {
+ t.Fatal(cleared.Body.String())
+ }
+ var library registry.ProjectLibrary
+ if err := json.Unmarshal(cleared.Body.Bytes(), &library); err != nil {
+ t.Fatal(err)
+ }
+ if len(library.Membership) != 0 || !reflect.DeepEqual(library.Folders, saved.Folders) || !reflect.DeepEqual(library.Order, saved.Order) {
+ t.Fatal("returning to the flat list changed folder/order", library)
+ }
+}
diff --git a/daemon/internal/registry/library.go b/daemon/internal/registry/library.go
new file mode 100644
index 0000000..44768cc
--- /dev/null
+++ b/daemon/internal/registry/library.go
@@ -0,0 +1,263 @@
+package registry
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "slices"
+ "strings"
+ "unicode/utf8"
+)
+
+const ProjectLibraryVersion = 1
+
+type ProjectFolder struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// ProjectLibrary is presentation metadata. None of its operations move files or
+// touch native harness transcripts. Order and membership use this registry's project IDs.
+type ProjectLibrary struct {
+ Version int `json:"version"`
+ Revision int64 `json:"revision"`
+ Folders []ProjectFolder `json:"folders"`
+ Membership map[string]string `json:"membership"`
+ Order []string `json:"order"`
+}
+
+type ProjectPlacement struct {
+ ProjectID string `json:"projectId"`
+ FolderID *string `json:"folderId"` // null returns a project to the flat list
+}
+
+// Edits are partial, atomic and conditional. Retrying an already applied edit
+// succeeds without a new revision; a stale, different intent returns ErrConflict.
+type ProjectLibraryEdit struct {
+ ExpectedRevision int64 `json:"expectedRevision"`
+ Folders []ProjectFolder `json:"folders,omitempty"`
+ DeleteFolders []string `json:"deleteFolders,omitempty"`
+ Placements []ProjectPlacement `json:"placements,omitempty"`
+ Order []string `json:"order,omitempty"`
+ FolderOrder []string `json:"folderOrder,omitempty"`
+ ImportIfEmpty bool `json:"importIfEmpty,omitempty"`
+}
+
+func emptyLibrary() ProjectLibrary {
+ return ProjectLibrary{Version: ProjectLibraryVersion, Folders: []ProjectFolder{}, Membership: map[string]string{}, Order: []string{}}
+}
+
+func readLibrary(tx *sql.Tx) (ProjectLibrary, error) {
+ library := emptyLibrary()
+ var data []byte
+ err := tx.QueryRow("SELECT data FROM project_library WHERE id=1").Scan(&data)
+ if errors.Is(err, sql.ErrNoRows) {
+ return library, nil
+ }
+ if err != nil {
+ return library, err
+ }
+ err = json.Unmarshal(data, &library)
+ return library, err
+}
+
+func saveLibrary(tx *sql.Tx, library ProjectLibrary, revision int64) error {
+ library.Revision = revision
+ data, err := json.Marshal(library)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec("INSERT INTO project_library(id,data) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data", data)
+ return err
+}
+
+// ProjectLibrary reads only organization metadata, without enumerating projects
+// or native conversations. It shares the registry's transaction boundary.
+func (st *Store) ProjectLibrary() (ProjectLibrary, error) {
+ tx, err := st.db.Begin()
+ if err != nil {
+ return ProjectLibrary{}, err
+ }
+ defer tx.Rollback()
+ library, err := readLibrary(tx)
+ if err != nil {
+ return library, err
+ }
+ return library, tx.Commit()
+}
+
+func libraryOrder(base, requested []string) ([]string, error) {
+ seen := map[string]bool{}
+ for _, id := range requested {
+ if !validID(id) || seen[id] {
+ return nil, invalid("duplicate or invalid ordered ID")
+ }
+ seen[id] = true
+ }
+ base = slices.Clone(base)
+ for _, id := range requested {
+ if !slices.Contains(base, id) {
+ base = append(base, id)
+ }
+ }
+ index := 0
+ for i, id := range base {
+ if seen[id] {
+ base[i] = requested[index]
+ index++
+ }
+ }
+ return base, nil
+}
+
+func (st *Store) EditProjectLibrary(edit ProjectLibraryEdit) error {
+ if edit.ExpectedRevision < 0 || len(edit.Folders)+len(edit.DeleteFolders)+len(edit.Placements)+len(edit.Order)+len(edit.FolderOrder) > 20000 {
+ return invalid("project library edit is too large or has an invalid revision")
+ }
+ return st.write(func(tx *sql.Tx, revision int64) (bool, error) {
+ before, err := readLibrary(tx)
+ if err != nil {
+ return false, err
+ }
+ if edit.ImportIfEmpty && before.Revision != 0 {
+ return false, nil
+ }
+ next := before
+ next.Folders = slices.Clone(before.Folders)
+ next.Membership = map[string]string{}
+ for k, v := range before.Membership {
+ next.Membership[k] = v
+ }
+ deleted := map[string]bool{}
+ newDeletion := false
+ for _, id := range edit.DeleteFolders {
+ if !validID(id) {
+ return false, invalid("folder ID")
+ }
+ deleted[id] = true
+ var removed int
+ if err := tx.QueryRow("SELECT count(*) FROM project_folder_deletions WHERE id=?", id).Scan(&removed); err != nil {
+ return false, err
+ }
+ newDeletion = newDeletion || removed == 0
+ }
+ next.Folders = slices.DeleteFunc(next.Folders, func(f ProjectFolder) bool { return deleted[f.ID] })
+ for id, folder := range next.Membership {
+ if deleted[folder] {
+ delete(next.Membership, id)
+ }
+ }
+ for _, folder := range edit.Folders {
+ folder.Name = strings.TrimSpace(folder.Name)
+ if !validID(folder.ID) || deleted[folder.ID] || folder.Name == "" || utf8.RuneCountInString(folder.Name) > 80 || strings.ContainsAny(folder.Name, "\x00\r\n") {
+ return false, invalid("folder name or ID")
+ }
+ index := slices.IndexFunc(next.Folders, func(f ProjectFolder) bool { return f.ID == folder.ID })
+ var removed int
+ if err := tx.QueryRow("SELECT count(*) FROM project_folder_deletions WHERE id=?", folder.ID).Scan(&removed); err != nil {
+ return false, err
+ }
+ if removed > 0 {
+ return false, ErrDeleted
+ }
+ if index >= 0 {
+ next.Folders[index] = folder
+ } else {
+ next.Folders = append(next.Folders, folder)
+ }
+ }
+ folders := map[string]ProjectFolder{}
+ for i, folder := range next.Folders {
+ for _, other := range next.Folders[:i] {
+ if strings.EqualFold(folder.Name, other.Name) {
+ return false, invalid("a folder with this name already exists")
+ }
+ }
+ folders[folder.ID] = folder
+ }
+ for _, placement := range edit.Placements {
+ if !validID(placement.ProjectID) {
+ return false, invalid("project ID")
+ }
+ if placement.FolderID == nil {
+ delete(next.Membership, placement.ProjectID)
+ continue
+ }
+ if _, exists := folders[*placement.FolderID]; !exists {
+ return false, invalid("folder no longer exists")
+ }
+ project, _, err := record(tx, "projects", placement.ProjectID)
+ if err != nil {
+ return false, err
+ }
+ if project == nil {
+ return false, invalid("project no longer exists")
+ }
+ next.Membership[placement.ProjectID] = *placement.FolderID
+ }
+ for _, id := range edit.Order {
+ project, _, err := record(tx, "projects", id)
+ if err != nil {
+ return false, err
+ }
+ if project == nil {
+ return false, invalid("ordered project no longer exists")
+ }
+ }
+ if len(edit.Order) > 0 {
+ next.Order, err = libraryOrder(before.Order, edit.Order)
+ if err != nil {
+ return false, err
+ }
+ }
+ if len(edit.FolderOrder) > 0 {
+ ids := make([]string, 0, len(next.Folders))
+ for _, f := range next.Folders {
+ ids = append(ids, f.ID)
+ }
+ for _, id := range edit.FolderOrder {
+ if _, ok := folders[id]; !ok {
+ return false, invalid("ordered folder no longer exists")
+ }
+ }
+ ids, err = libraryOrder(ids, edit.FolderOrder)
+ if err != nil {
+ return false, err
+ }
+ next.Folders = make([]ProjectFolder, 0, len(ids))
+ for _, id := range ids {
+ next.Folders = append(next.Folders, folders[id])
+ }
+ }
+ if len(next.Folders) > 10000 {
+ return false, invalid("too many project folders")
+ }
+ if reflect.DeepEqual(before, next) && !newDeletion {
+ return false, nil
+ }
+ if edit.ExpectedRevision != before.Revision {
+ return false, ErrConflict
+ }
+ for id := range deleted {
+ if _, err := tx.Exec("INSERT OR IGNORE INTO project_folder_deletions(id) VALUES(?)", id); err != nil {
+ return false, err
+ }
+ }
+ return true, saveLibrary(tx, next, revision)
+ })
+}
+
+func removeProjectPlacement(tx *sql.Tx, id string, revision int64) error {
+ library, err := readLibrary(tx)
+ if err != nil {
+ return err
+ }
+ _, member := library.Membership[id]
+ if !member && !slices.Contains(library.Order, id) {
+ return nil
+ }
+ delete(library.Membership, id)
+ library.Order = slices.DeleteFunc(library.Order, func(value string) bool { return value == id })
+ return saveLibrary(tx, library, revision)
+}
diff --git a/daemon/internal/registry/library_test.go b/daemon/internal/registry/library_test.go
new file mode 100644
index 0000000..38803d5
--- /dev/null
+++ b/daemon/internal/registry/library_test.go
@@ -0,0 +1,214 @@
+package registry
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func librarySnapshot(t *testing.T, st *Store) ProjectLibrary {
+ t.Helper()
+ library, err := st.ProjectLibrary()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return library
+}
+
+func TestProjectLibrarySurvivesRestartAndOnlyChangesOrganization(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "workspace.db")
+ st, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { st.Close() }()
+ if err := st.Import(fixture()); err != nil {
+ t.Fatal(err)
+ }
+ before := snapshot(t, st, nil)
+ if before.ProjectLibrary == nil || before.ProjectLibrary.Version != 1 || before.ProjectLibrary.Revision != 0 {
+ t.Fatal("full snapshots must expose the empty library for feature discovery")
+ }
+ if snapshot(t, st, &before.Revision).ProjectLibrary != nil {
+ t.Fatal("unchanged library must not be repeated in every delta")
+ }
+ folder := "work"
+ edit := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}},
+ Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}}
+ if err := st.EditProjectLibrary(edit); err != nil {
+ t.Fatal(err)
+ }
+ after := snapshot(t, st, nil)
+ if !reflect.DeepEqual(before.Import, after.Import) || len(after.Deleted) != 0 {
+ t.Fatal("organizing projects changed their records or conversations")
+ }
+ delta := snapshot(t, st, &before.Revision)
+ if delta.ProjectLibrary == nil || len(delta.Projects)+len(delta.Agents)+len(delta.Chats) != 0 {
+ t.Fatalf("library-only delta: %+v", delta)
+ }
+ if err := st.EditProjectLibrary(edit); err != nil || snapshot(t, st, nil).Revision != after.Revision {
+ t.Fatalf("retry after a lost acknowledgement was not idempotent: %v", err)
+ }
+ if err := st.Close(); err != nil {
+ t.Fatal(err)
+ }
+ st, err = Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := librarySnapshot(t, st); !reflect.DeepEqual(got, *after.ProjectLibrary) {
+ t.Fatalf("restart lost organization: %+v", got)
+ }
+}
+
+func TestProjectLibraryConcurrentClientsAndConditionalRetry(t *testing.T) {
+ st := openTest(t)
+ var winners, conflicts atomic.Int32
+ var wg sync.WaitGroup
+ for _, name := range []string{"First", "Second"} {
+ wg.Add(1)
+ go func(name string) {
+ defer wg.Done()
+ err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: name, Name: name}}})
+ if err == nil {
+ winners.Add(1)
+ } else if errors.Is(err, ErrConflict) {
+ conflicts.Add(1)
+ } else {
+ t.Errorf("edit: %v", err)
+ }
+ }(name)
+ }
+ wg.Wait()
+ if winners.Load() != 1 || conflicts.Load() != 1 {
+ t.Fatalf("winners=%d conflicts=%d", winners.Load(), conflicts.Load())
+ }
+ before := librarySnapshot(t, st)
+ folder := before.Folders[0]
+ folder.Name = "Renamed"
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{folder}}); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale edit overwrote another device: %v", err)
+ }
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision, Folders: []ProjectFolder{folder}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProjectLibraryInvalidBatchRollsBackEveryPart(t *testing.T) {
+ st := openTest(t)
+ if err := st.Import(fixture()); err != nil {
+ t.Fatal(err)
+ }
+ before := snapshot(t, st, nil)
+ id := "folder"
+ for _, edit := range []ProjectLibraryEdit{
+ {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, Placements: []ProjectPlacement{{ProjectID: "missing", FolderID: &id}}},
+ {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, Order: []string{"missing"}},
+ {DeleteFolders: []string{id}, Folders: []ProjectFolder{{ID: id, Name: "Conflicting intent"}}},
+ {Folders: []ProjectFolder{{ID: id, Name: "Work"}, {ID: "duplicate", Name: "work"}}},
+ {Folders: []ProjectFolder{{ID: id, Name: "Work"}}, FolderOrder: []string{"missing"}},
+ } {
+ if err := st.EditProjectLibrary(edit); !errors.Is(err, ErrInvalid) {
+ t.Fatalf("invalid batch accepted: %+v %v", edit, err)
+ }
+ if after := snapshot(t, st, nil); !reflect.DeepEqual(before, after) {
+ t.Fatal("an invalid batch partially committed")
+ }
+ }
+ // A rolled-back deletion must not leave an invisible tombstone.
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: id, Name: "Work"}}}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestProjectLibraryScopedOrderingPreservesOtherSlots(t *testing.T) {
+ st := openTest(t)
+ batch := Import{}
+ for _, id := range []string{"a", "b", "c", "d"} {
+ batch.Projects = append(batch.Projects, Project{Record: Record{ID: id}, Name: id, Path: "/work/" + id})
+ }
+ if err := st.Import(batch); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{Order: []string{"a", "b", "c", "d"},
+ Folders: []ProjectFolder{{ID: "one", Name: "One"}, {ID: "two", Name: "Two"}, {ID: "three", Name: "Three"}}}); err != nil {
+ t.Fatal(err)
+ }
+ before := librarySnapshot(t, st)
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision,
+ Order: []string{"c", "a"}, FolderOrder: []string{"three", "one"}}); err != nil {
+ t.Fatal(err)
+ }
+ after := librarySnapshot(t, st)
+ if !reflect.DeepEqual(after.Order, []string{"c", "b", "a", "d"}) || after.Folders[1].ID != "two" || after.Folders[0].ID != "three" {
+ t.Fatalf("scoped reorder moved unrelated projects/folders: %+v", after)
+ }
+}
+
+func TestProjectFolderDeletionKeepsFilesChatsAndBlocksResurrection(t *testing.T) {
+ st := openTest(t)
+ batch := fixture()
+ batch.Projects[0].Path = t.TempDir()
+ file := filepath.Join(batch.Projects[0].Path, "keep.txt")
+ if err := os.WriteFile(file, []byte("keep my work"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.Import(batch); err != nil {
+ t.Fatal(err)
+ }
+ folder := "work"
+ create := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}},
+ Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}}
+ if err := st.EditProjectLibrary(create); err != nil {
+ t.Fatal(err)
+ }
+ before := snapshot(t, st, nil)
+ remove := ProjectLibraryEdit{ExpectedRevision: before.ProjectLibrary.Revision, DeleteFolders: []string{folder}}
+ if err := st.EditProjectLibrary(remove); err != nil {
+ t.Fatal(err)
+ }
+ after := snapshot(t, st, nil)
+ if len(after.ProjectLibrary.Folders)+len(after.ProjectLibrary.Membership) != 0 || len(after.ProjectLibrary.Order) != 1 || !reflect.DeepEqual(after.Import, before.Import) {
+ t.Fatalf("folder deletion touched its projects: %+v", after)
+ }
+ if data, err := os.ReadFile(file); err != nil || string(data) != "keep my work" {
+ t.Fatal("folder deletion changed files")
+ }
+ if err := st.EditProjectLibrary(remove); err != nil || snapshot(t, st, nil).Revision != after.Revision {
+ t.Fatal("repeated deletion must be a no-op", err)
+ }
+ create.ExpectedRevision = after.Revision
+ if err := st.EditProjectLibrary(create); !errors.Is(err, ErrDeleted) {
+ t.Fatalf("offline client resurrected deleted folder: %v", err)
+ }
+ if err := st.Delete("projects", "project", &after.Projects[0].Revision, false); err != nil {
+ t.Fatal(err)
+ }
+ delta := snapshot(t, st, &after.Revision)
+ if delta.ProjectLibrary == nil || len(delta.ProjectLibrary.Order) != 0 || len(delta.Deleted) != 2 {
+ t.Fatalf("project deletion left library references: %+v", delta)
+ }
+}
+
+func TestLibraryMigrationCannotReplaceExistingOrganizationOrDeletedFolder(t *testing.T) {
+ st := openTest(t)
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{DeleteFolders: []string{"offline"}}); err != nil {
+ t.Fatal(err)
+ }
+ before := librarySnapshot(t, st)
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{ExpectedRevision: before.Revision,
+ Folders: []ProjectFolder{{ID: "offline", Name: "Never uploaded"}}}); !errors.Is(err, ErrDeleted) {
+ t.Fatalf("deleting an unacknowledged folder did not suppress its delayed upload: %v", err)
+ }
+ if err := st.EditProjectLibrary(ProjectLibraryEdit{ImportIfEmpty: true,
+ Folders: []ProjectFolder{{ID: "stale", Name: "Local copy"}}}); err != nil {
+ t.Fatal(err)
+ }
+ if got := librarySnapshot(t, st); !reflect.DeepEqual(got, before) {
+ t.Fatal("local-only migration overwrote another client's organization")
+ }
+}
diff --git a/daemon/internal/registry/store.go b/daemon/internal/registry/store.go
index bed61d8..4ae2f38 100644
--- a/daemon/internal/registry/store.go
+++ b/daemon/internal/registry/store.go
@@ -26,7 +26,7 @@ import (
const Version = 1
const NotificationPreferencesVersion = 1
-const schemaVersion = 6
+const schemaVersion = 7
var (
ErrConflict = errors.New("record changed on another client; refresh and try again")
@@ -94,6 +94,7 @@ type Snapshot struct {
Full bool `json:"full"`
Deleted []Deletion `json:"deleted"`
SessionDiscoveryIssues []SessionDiscoveryIssue `json:"sessionDiscoveryIssues,omitempty"`
+ ProjectLibrary *ProjectLibrary `json:"projectLibrary,omitempty"`
}
type Store struct {
@@ -181,7 +182,9 @@ CREATE INDEX IF NOT EXISTS native_chat_links_chat ON native_chat_links(chat_id);
CREATE TABLE IF NOT EXISTS project_sync_locks (id TEXT PRIMARY KEY, path TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS project_sync_records (kind TEXT NOT NULL, id TEXT NOT NULL, data BLOB NOT NULL,
PRIMARY KEY(kind,id));
-PRAGMA user_version=6;`); err != nil {
+CREATE TABLE IF NOT EXISTS project_library (id INTEGER PRIMARY KEY CHECK(id=1), data BLOB NOT NULL);
+CREATE TABLE IF NOT EXISTS project_folder_deletions (id TEXT PRIMARY KEY);
+PRAGMA user_version=7;`); err != nil {
return err
}
identity := make([]byte, 16)
@@ -639,6 +642,11 @@ func deleteRecord(tx *sql.Tx, kind, id string, expected *int64, force bool, revi
if _, err = tx.Exec("DELETE FROM "+kind+" WHERE id=?", id); err != nil {
return false, err
}
+ if kind == "projects" {
+ if err := removeProjectPlacement(tx, id, revision); err != nil {
+ return false, err
+ }
+ }
_, err = tx.Exec("INSERT INTO deleted VALUES (?,?,?)", kind, id, revision)
return true, err
}
@@ -721,6 +729,13 @@ func (st *Store) Snapshot(since *int64) (Snapshot, error) {
if err != nil {
return s, err
}
+ library, err := readLibrary(tx)
+ if err != nil {
+ return s, err
+ }
+ if library.Revision > minimum {
+ s.ProjectLibrary = &library
+ }
return s, tx.Commit()
}
diff --git a/daemon/internal/surface/binding.go b/daemon/internal/surface/binding.go
index eb87dea..64a6a8e 100644
--- a/daemon/internal/surface/binding.go
+++ b/daemon/internal/surface/binding.go
@@ -99,11 +99,13 @@ func (s *Service) Bind(binding Binding, credentials Credentials) error {
s.mu.Lock()
s.provider = provider
s.snapshot.Geometry = nil
+ s.snapshot.Cursor = nil
s.snapshot.Error = nil
s.snapshot.State = "disconnected"
expires := provider.ExpiresAt()
s.snapshot.AuthorizationExpiresAt = &expires
s.signalLocked()
s.mu.Unlock()
+ s.observeProviderCursor(provider)
return nil
}
diff --git a/daemon/internal/surface/catalog.go b/daemon/internal/surface/catalog.go
index 8b3b00b..24b2d5e 100644
--- a/daemon/internal/surface/catalog.go
+++ b/daemon/internal/surface/catalog.go
@@ -281,6 +281,7 @@ func (s *Service) ForDesktop(id string) (*Service, error) {
child := &Service{db: s.db, artifacts: s.artifacts, surfaceID: id, provider: p, approval: approval,
sessions: map[string]*sessionState{}, openIDs: map[string]string{}, viewers: map[string]io.ReadWriteCloser{}, changed: make(chan struct{}), stop: make(chan struct{}), now: s.now}
child.snapshot = Snapshot{ID: id, DesktopID: id, WorkspaceID: s.db.Identity(), Kind: "desktop", Provider: "oblien", Version: Version, InstanceID: newID(), State: "disconnected"}
+ child.observeProviderCursor(p)
expiry := p.ExpiresAt()
if !expiry.IsZero() {
child.snapshot.AuthorizationExpiresAt = &expiry
diff --git a/daemon/internal/surface/cursor.go b/daemon/internal/surface/cursor.go
new file mode 100644
index 0000000..ff42676
--- /dev/null
+++ b/daemon/internal/surface/cursor.go
@@ -0,0 +1,132 @@
+package surface
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "image"
+ "image/png"
+ "io"
+
+ vnc "github.com/kward/go-vnc"
+ "github.com/kward/go-vnc/encodings"
+)
+
+// Cursor is a small, immutable remote cursor image, not a screenshot. Its ID
+// changes only when the pixels or hotspot change; moving it sends no new image.
+type Cursor struct {
+ ID string `json:"id"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ HotspotX int `json:"hotspotX"`
+ HotspotY int `json:"hotspotY"`
+ PNG string `json:"png"`
+}
+
+const cursorEncodingType encodings.Encoding = -239 // RFB RichCursor
+const maxCursorSize = 256
+
+type cursorEncoding struct {
+ reader *frameReader
+ cursor *Cursor
+}
+
+func (*cursorEncoding) Type() encodings.Encoding { return cursorEncodingType }
+func (*cursorEncoding) String() string { return "RichCursor" }
+func (*cursorEncoding) Marshal() ([]byte, error) { return nil, nil }
+func (e *cursorEncoding) Read(_ *vnc.ClientConn, rect *vnc.Rectangle) (vnc.Encoding, error) {
+ w, h := int(rect.Width), int(rect.Height)
+ if w > maxCursorSize || h > maxCursorSize || (w > 0 && int(rect.X) >= w) || (h > 0 && int(rect.Y) >= h) {
+ return nil, errors.New("RFB cursor exceeds limit")
+ }
+ if w == 0 || h == 0 {
+ // TigerVNC sends an empty cursor when another connection moves the mouse
+ // and starts rendering it into that viewer's pixels. Keep our last shape.
+ return &cursorEncoding{}, nil
+ }
+ if w*h > e.reader.remainingPixels {
+ return nil, errors.New("RFB cursor exceeds frame limit")
+ }
+ e.reader.remainingPixels -= w * h
+ rowBytes := (w + 7) / 8
+ data := make([]byte, w*h*4+rowBytes*h)
+ if _, err := io.ReadFull(e.reader.conn, data); err != nil {
+ return nil, err
+ }
+ img := image.NewNRGBA(image.Rect(0, 0, w, h))
+ mask := data[w*h*4:]
+ visible := false
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ if mask[y*rowBytes+x/8]&(0x80>>uint(x%8)) == 0 {
+ continue
+ }
+ i := (y*w + x) * 4
+ img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = data[i+2], data[i+1], data[i], 255
+ visible = true
+ }
+ }
+ if !visible {
+ return &cursorEncoding{}, nil
+ }
+ var encoded bytes.Buffer
+ if err := png.Encode(&encoded, img); err != nil {
+ return nil, err
+ }
+ hash := sha256.New()
+ hash.Write(encoded.Bytes())
+ hash.Write([]byte{byte(rect.X), byte(rect.Y)})
+ return &cursorEncoding{cursor: &Cursor{
+ ID: hex.EncodeToString(hash.Sum(nil)), Width: w, Height: h,
+ HotspotX: int(rect.X), HotspotY: int(rect.Y), PNG: base64.StdEncoding.EncodeToString(encoded.Bytes()),
+ }}, nil
+}
+
+func (r *rfbClient) setCursorObserver(fn func(*Cursor)) {
+ r.mu.Lock()
+ r.onCursor = fn
+ cursor := r.cursor
+ r.mu.Unlock()
+ if fn != nil && cursor != nil {
+ fn(cursor)
+ }
+ r.wakeUpdates()
+}
+
+// The input connection is authoritative: TigerVNC can suppress cursor images
+// on a separate video-only connection after the controller moves the pointer.
+func (p *Oblien) SetCursorObserver(fn func(*Cursor)) {
+ p.mu.Lock()
+ p.onCursor = fn
+ client := p.client
+ p.mu.Unlock()
+ if client != nil {
+ client.setCursorObserver(fn)
+ }
+}
+
+func (p *MacDesktop) SetCursorObserver(fn func(*Cursor)) {
+ p.mu.Lock()
+ p.onCursor = fn
+ client := p.client
+ p.mu.Unlock()
+ if client != nil {
+ client.setCursorObserver(fn)
+ }
+}
+
+func (s *Service) observeProviderCursor(p Provider) {
+ if source, ok := p.(interface{ SetCursorObserver(func(*Cursor)) }); ok {
+ source.SetCursorObserver(func(cursor *Cursor) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.provider != p || cursor == nil || (s.snapshot.Cursor != nil && s.snapshot.Cursor.ID == cursor.ID) {
+ return
+ }
+ s.snapshot.Cursor = cursor
+ s.signalLocked()
+ })
+ }
+}
diff --git a/daemon/internal/surface/cursor_test.go b/daemon/internal/surface/cursor_test.go
new file mode 100644
index 0000000..acad3f8
--- /dev/null
+++ b/daemon/internal/surface/cursor_test.go
@@ -0,0 +1,122 @@
+package surface
+
+import (
+ "bytes"
+ "encoding/base64"
+ "image"
+ "image/png"
+ "net"
+ "testing"
+
+ vnc "github.com/kward/go-vnc"
+)
+
+func TestRichCursorPreservesColorTransparencyAndHotspot(t *testing.T) {
+ local, remote := net.Pipe()
+ defer local.Close()
+ defer remote.Close()
+ go func() {
+ // Two BGRA pixels, followed by a mask exposing only the first.
+ _, _ = remote.Write([]byte{40, 30, 200, 0, 2, 3, 4, 0, 0x80})
+ }()
+ reader := &cursorEncoding{reader: &frameReader{conn: local, remainingPixels: 100}}
+ decoded, err := reader.Read(nil, &vnc.Rectangle{X: 1, Width: 2, Height: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ cursor := decoded.(*cursorEncoding).cursor
+ if cursor == nil || cursor.Width != 2 || cursor.Height != 1 || cursor.HotspotX != 1 || cursor.HotspotY != 0 {
+ t.Fatalf("missing cursor geometry: %+v", cursor)
+ }
+ data, err := base64.StdEncoding.DecodeString(cursor.PNG)
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r, g, b, a := img.At(0, 0).RGBA()
+ if r != 200*257 || g != 30*257 || b != 40*257 || a != 65535 {
+ t.Fatal("cursor colors did not follow the negotiated wire format")
+ }
+ _, _, _, a = img.At(1, 0).RGBA()
+ if a != 0 {
+ t.Fatal("cursor transparency mask was lost")
+ }
+ for _, rect := range []vnc.Rectangle{{Width: 257, Height: 1}, {Width: 2, Height: 1, X: 2}, {Width: 256, Height: 256}} {
+ if _, err := reader.Read(nil, &rect); err == nil {
+ t.Fatal("unsafe cursor dimensions or frame allocation accepted")
+ }
+ }
+}
+
+func TestCursorReplyCannotAcknowledgeCaptureAndDuplicateShapesDoNotEmit(t *testing.T) {
+ o := &frameObservation{full: true}
+ o.reset(Geometry{130, 3, 1})
+ o.sent = true
+ client := &rfbClient{frame: image.NewRGBA(image.Rect(0, 0, 130, 3)), size: o.size,
+ observation: o, changed: make(chan struct{}), done: make(chan struct{})}
+ updates := 0
+ client.onCursor = func(*Cursor) { updates++ }
+ for range 2 {
+ messages := make(chan vnc.ServerMessage, 1)
+ messages <- &vnc.FramebufferUpdate{Rects: []vnc.Rectangle{{Enc: &cursorEncoding{cursor: &Cursor{ID: "text"}}}}}
+ close(messages)
+ client.receive(messages)
+ }
+ if o.remaining != 390 || !client.observedAt.IsZero() {
+ t.Fatal("a cursor update acknowledged screenshot pixels or fresh geometry")
+ }
+ if updates != 1 {
+ t.Fatalf("unchanged cursor emitted %d updates", updates)
+ }
+ if o.sent {
+ t.Fatal("cursor-only reply did not rearm the missing screenshot request")
+ }
+ o.sent = true
+ for _, rect := range []image.Rectangle{image.Rect(0, 0, 65, 3), image.Rect(64, 0, 130, 2), image.Rect(0, 0, 100, 3)} {
+ o.cover(rect)
+ }
+ if o.remaining != 30 {
+ t.Fatalf("overlapping tiles falsely acknowledged missing pixels: %d", o.remaining)
+ }
+ o.cover(image.Rect(100, 2, 130, 3))
+ if o.remaining != 0 {
+ t.Fatal("complete tiled image was not acknowledged")
+ }
+}
+
+type cursorProvider struct {
+ Provider
+ onCursor func(*Cursor)
+}
+
+func (p *cursorProvider) SetCursorObserver(fn func(*Cursor)) { p.onCursor = fn }
+
+func TestCursorObserverRejectsReplacedProviderAndSnapshotsAreImmutable(t *testing.T) {
+ s, p, _ := testService(t)
+ defer s.Close()
+ first := &cursorProvider{Provider: p}
+ if err := s.Configure(first); err != nil {
+ t.Fatal(err)
+ }
+ first.onCursor(&Cursor{ID: "first"})
+ copy := s.Snapshot()
+ copy.Cursor.ID = "mutated copy"
+ if s.Snapshot().Cursor.ID != "first" {
+ t.Fatal("a caller mutated the shared cursor")
+ }
+ second := &cursorProvider{Provider: p}
+ if err := s.Configure(second); err != nil {
+ t.Fatal(err)
+ }
+ first.onCursor(&Cursor{ID: "late"})
+ if s.Snapshot().Cursor != nil {
+ t.Fatal("old desktop supplied the replacement's cursor")
+ }
+ second.onCursor(&Cursor{ID: "current"})
+ if s.Snapshot().Cursor.ID != "current" {
+ t.Fatal("current desktop cursor was not forwarded")
+ }
+}
diff --git a/daemon/internal/surface/mac.go b/daemon/internal/surface/mac.go
index 5df9703..8388aaa 100644
--- a/daemon/internal/surface/mac.go
+++ b/daemon/internal/surface/mac.go
@@ -48,6 +48,7 @@ type MacDesktop struct {
settings LocalDesktopSettings
username string
client *rfbClient
+ onCursor func(*Cursor)
inputDelay time.Duration
inputReadyAt time.Time
dial func(context.Context) (net.Conn, error)
@@ -178,6 +179,7 @@ func (p *MacDesktop) Connect(ctx context.Context) (Geometry, error) {
return Geometry{}, macConnectionError(ctx, err)
}
p.client = client
+ client.setCursorObserver(p.onCursor)
// Screen Sharing can deliver frames before its input session is ready. macOS
// silently discards early events, so delay only initial input, not the video.
p.inputReadyAt = time.Now().Add(p.inputDelay)
@@ -221,6 +223,16 @@ func (p *MacDesktop) Capture(ctx context.Context) (image.Image, Geometry, error)
return client.capture(ctx)
}
+func (p *MacDesktop) InputGeometry(ctx context.Context, action Action) (Geometry, error) {
+ p.mu.Lock()
+ client := p.client
+ p.mu.Unlock()
+ if client == nil || !client.alive() {
+ return p.Connect(ctx)
+ }
+ return client.inputGeometry(ctx, action)
+}
+
func (p *MacDesktop) PrepareControl(ctx context.Context) error {
p.mu.Lock()
client, readyAt := p.client, p.inputReadyAt
diff --git a/daemon/internal/surface/native_fixture_test.go b/daemon/internal/surface/native_fixture_test.go
index 9bd183a..e75e0b5 100644
--- a/daemon/internal/surface/native_fixture_test.go
+++ b/daemon/internal/surface/native_fixture_test.go
@@ -29,6 +29,22 @@ func registerDesktopFixtureRoutes(mux *http.ServeMux, s *Service) {
respond(w, s.Snapshot(), nil)
}
})
+ mux.HandleFunc("GET /surfaces/desktop/events", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ for {
+ changed := s.Changes()
+ data, _ := json.Marshal(s.Snapshot())
+ if _, err := w.Write(append(append([]byte("event: surface\ndata: "), data...), []byte("\n\n")...)); err != nil {
+ return
+ }
+ w.(http.Flusher).Flush()
+ select {
+ case <-r.Context().Done():
+ return
+ case <-changed:
+ }
+ }
+ })
mux.HandleFunc("POST /surfaces/desktop/sessions", func(w http.ResponseWriter, r *http.Request) {
var req OpenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
diff --git a/daemon/internal/surface/oblien.go b/daemon/internal/surface/oblien.go
index e0b6e8c..7a0cead 100644
--- a/daemon/internal/surface/oblien.go
+++ b/daemon/internal/surface/oblien.go
@@ -47,13 +47,14 @@ type VNCSettings struct {
}
type Oblien struct {
- mu sync.Mutex
- binding Binding
- http *http.Client
- base string
- client *rfbClient
- status ProviderStatus
- target string
+ mu sync.Mutex
+ binding Binding
+ http *http.Client
+ base string
+ client *rfbClient
+ onCursor func(*Cursor)
+ status ProviderStatus
+ target string
}
func NewOblien(binding Binding) (*Oblien, error) {
@@ -253,7 +254,9 @@ func (p *Oblien) Connect(ctx context.Context) (Geometry, error) {
}
p.mu.Lock()
p.client = rfb
+ onCursor := p.onCursor
p.mu.Unlock()
+ rfb.setCursorObserver(onCursor)
return rfb.geometry(), nil
}
func (p *Oblien) Capture(ctx context.Context) (image.Image, Geometry, error) {
@@ -265,6 +268,19 @@ func (p *Oblien) Capture(ctx context.Context) (image.Image, Geometry, error) {
p.mu.Unlock()
return client.capture(ctx)
}
+
+func (p *Oblien) InputGeometry(ctx context.Context, action Action) (Geometry, error) {
+ if err := p.expired(); err != nil {
+ return Geometry{}, err
+ }
+ p.mu.Lock()
+ client := p.client
+ p.mu.Unlock()
+ if client == nil || !client.alive() {
+ return p.Connect(ctx)
+ }
+ return client.inputGeometry(ctx, action)
+}
func (p *Oblien) Apply(ctx context.Context, a Action) (string, error) {
// Service.Apply refreshes geometry before spatial validation. Reusing the live
// connection here avoids a second frame round-trip for every pointer move, and
diff --git a/daemon/internal/surface/rfb.go b/daemon/internal/surface/rfb.go
index 20172a3..13a65df 100644
--- a/daemon/internal/surface/rfb.go
+++ b/daemon/internal/surface/rfb.go
@@ -99,19 +99,26 @@ func (m *boundedClipboard) Read(*vnc.ClientConn) (vnc.ServerMessage, error) {
}
type rfbClient struct {
- write sync.Mutex
- mu sync.Mutex
- conn net.Conn
- client *vnc.ClientConn
- frame *image.RGBA
- size Geometry
- sequence int64
- changed chan struct{}
- done chan struct{}
- closeOnce sync.Once
- pressed map[keys.Key]bool
- x, y uint16
- mask buttons.Button
+ observeLock sync.Mutex
+ write sync.Mutex
+ mu sync.Mutex
+ conn net.Conn
+ client *vnc.ClientConn
+ frame *image.RGBA
+ size Geometry
+ sequence int64
+ observedAt time.Time
+ changed chan struct{}
+ observation *frameObservation
+ updateWake chan struct{}
+ cursorPending bool
+ cursor *Cursor
+ onCursor func(*Cursor)
+ done chan struct{}
+ closeOnce sync.Once
+ pressed map[keys.Key]bool
+ x, y uint16
+ mask buttons.Button
}
func newRFB(ctx context.Context, conn net.Conn) (*rfbClient, error) {
@@ -157,14 +164,15 @@ func newRFBWithAuth(ctx context.Context, conn net.Conn, auth []vnc.ClientAuth) (
conn.Close()
return nil, err
}
- if err := client.SetEncodings(vnc.Encodings{&boundedRaw{reader: reader}, &vnc.DesktopSizePseudoEncoding{}}); err != nil {
+ if err := client.SetEncodings(vnc.Encodings{&boundedRaw{reader: reader}, &vnc.DesktopSizePseudoEncoding{}, &cursorEncoding{reader: reader}}); err != nil {
conn.Close()
return nil, err
}
r := &rfbClient{conn: conn, client: client, frame: image.NewRGBA(image.Rect(0, 0, width, height)), size: Geometry{width, height, time.Now().UnixMilli()},
- changed: make(chan struct{}), done: make(chan struct{}), pressed: map[keys.Key]bool{}}
+ changed: make(chan struct{}), done: make(chan struct{}), updateWake: make(chan struct{}, 1), pressed: map[keys.Key]bool{}}
go func() { _ = client.ListenAndHandle(); r.close(); close(cfg.ServerMessageCh) }()
go r.receive(cfg.ServerMessageCh)
+ go r.requestUpdates()
return r, nil
}
func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) {
@@ -179,7 +187,17 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) {
}
r.mu.Lock()
invalid := false
+ var cursorChanged *Cursor
+ pixelsChanged := false
+ r.cursorPending = false
for _, rect := range update.Rects {
+ if shape, ok := rect.Enc.(*cursorEncoding); ok {
+ if shape.cursor != nil && (r.cursor == nil || r.cursor.ID != shape.cursor.ID) {
+ r.cursor = shape.cursor
+ cursorChanged = shape.cursor
+ }
+ continue
+ }
if rect.Enc.Type() == encodings.DesktopSizePseudo {
w, h := int(rect.Width), int(rect.Height)
if w < 1 || h < 1 || w*h > 16<<20 {
@@ -191,6 +209,9 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) {
if w != r.size.Width || h != r.size.Height {
r.size = Geometry{w, h, r.size.Revision + 1}
r.frame = image.NewRGBA(image.Rect(0, 0, w, h))
+ if r.observation != nil {
+ r.observation.reset(r.size)
+ }
}
continue
}
@@ -211,13 +232,32 @@ func (r *rfbClient) receive(messages <-chan vnc.ServerMessage) {
r.frame.Pix[offset+2] = byte(c.B)
r.frame.Pix[offset+3] = 255
}
+ pixelsChanged = true
+ if r.observation != nil {
+ r.observation.cover(image.Rect(x, y, x+w, y+h))
+ }
}
r.sequence++
+ if r.observation != nil && r.observation.remaining > 0 {
+ // RFB may satisfy/merge a pending update with only a cursor or a
+ // partial region. Request the still-missing pixels again; waiting
+ // for an unsolicited second reply would stall a static desktop.
+ r.observation.sent = false
+ }
+ if pixelsChanged {
+ r.observedAt = time.Now()
+ }
close(r.changed)
r.changed = make(chan struct{})
+ onCursor := r.onCursor
r.mu.Unlock()
if invalid {
r.close()
+ } else {
+ if cursorChanged != nil && onCursor != nil {
+ onCursor(cursorChanged)
+ }
+ r.wakeUpdates()
}
}
}
@@ -240,44 +280,48 @@ func (r *rfbClient) refreshGeometry(ctx context.Context) (Geometry, error) {
return geometry, err
}
-// A one-pixel nonincremental request also elicits pending DesktopSize updates.
-// Validate pointer geometry without transferring a second full viewer stream.
-func (r *rfbClient) requestFrame(size Geometry, full bool) error {
- width, height := uint16(1), uint16(1)
- if full {
- width, height = uint16(size.Width), uint16(size.Height)
- }
+// Repeated human pointer motion uses the geometry already observed on this
+// connection. Button transitions and clicks still reconcile synchronously;
+// motion rechecks at most every 250ms, with no timer/work while idle. Agent
+// actions continue to validate every observation through Connect/Capture.
+func (r *rfbClient) inputGeometry(ctx context.Context, action Action) (Geometry, error) {
r.write.Lock()
- defer r.write.Unlock()
- return r.client.FramebufferUpdateRequest(false, 0, 0, width, height)
+ continuing := action.Kind == "pointer" && int(r.mask) == action.Buttons
+ r.write.Unlock()
+ r.mu.Lock()
+ size, observedAt := r.size, r.observedAt
+ r.mu.Unlock()
+ if continuing && r.alive() && !observedAt.IsZero() && time.Since(observedAt) < 250*time.Millisecond {
+ return size, nil
+ }
+ return r.refreshGeometry(ctx)
}
+
func (r *rfbClient) observe(ctx context.Context, full bool) (image.Image, Geometry, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
+ r.observeLock.Lock()
+ defer r.observeLock.Unlock()
+ if err := ctx.Err(); err != nil {
+ return nil, Geometry{}, err
+ }
stop := context.AfterFunc(ctx, r.close)
defer stop()
+ o := &frameObservation{full: full}
r.mu.Lock()
- sequence := r.sequence
- size := r.size
+ o.reset(r.size)
+ r.observation = o
r.mu.Unlock()
- err := r.requestFrame(size, full)
- if err != nil {
- r.close()
- return nil, Geometry{}, err
- }
+ r.wakeUpdates()
+ defer func() {
+ r.mu.Lock()
+ r.observation = nil
+ r.mu.Unlock()
+ r.wakeUpdates()
+ }()
for {
r.mu.Lock()
- if r.size.Revision != size.Revision {
- size = r.size
- sequence = r.sequence
- r.mu.Unlock()
- err := r.requestFrame(size, full)
- if err != nil {
- return nil, Geometry{}, err
- }
- continue
- }
- if r.sequence > sequence {
+ if o.remaining == 0 && r.alive() {
if !full {
g := r.size
r.mu.Unlock()
diff --git a/daemon/internal/surface/rfb_test.go b/daemon/internal/surface/rfb_test.go
index 11bb894..c28db4f 100644
--- a/daemon/internal/surface/rfb_test.go
+++ b/daemon/internal/surface/rfb_test.go
@@ -25,16 +25,19 @@ type wireInput struct {
data []byte
}
type rfbFixture struct {
- mu sync.Mutex
- inputs []wireInput
- resize bool
- largeFrame bool
- tiledFrame bool
- debug bool
- frameRequests int
- formats16 int
- formats24 int
- authentication func(io.ReadWriteCloser) bool
+ mu sync.Mutex
+ inputs []wireInput
+ resize bool
+ largeFrame bool
+ tiledFrame bool
+ debug bool
+ frameRequests int
+ formats16 int
+ formats24 int
+ authentication func(io.ReadWriteCloser) bool
+ cursorBeforeFrame bool
+ cursorSent chan struct{}
+ continueFrame <-chan struct{}
}
func (f *rfbFixture) serve(conn io.ReadWriteCloser) {
@@ -77,6 +80,7 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) {
}
resized := false
sentPixels := false
+ sentCursor := false
pixel := []byte{40, 30, 200, 0}
debugMessages := 0
for {
@@ -126,6 +130,27 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) {
if err != nil {
return
}
+ if f.cursorBeforeFrame {
+ if request[0] != 0 {
+ continue // An idle incremental cursor request stays pending.
+ }
+ if !sentCursor {
+ var cursor bytes.Buffer
+ cursor.Write([]byte{0, 0, 0, 1})
+ for _, value := range []uint16{1, 0, 2, 2} {
+ _ = binary.Write(&cursor, binary.BigEndian, value)
+ }
+ _ = binary.Write(&cursor, binary.BigEndian, int32(cursorEncodingType))
+ cursor.Write(bytes.Repeat([]byte{255, 255, 255, 0}, 4))
+ cursor.Write([]byte{0xc0, 0xc0})
+ if _, err := conn.Write(cursor.Bytes()); err != nil {
+ return
+ }
+ close(f.cursorSent)
+ <-f.continueFrame
+ sentCursor = true
+ }
+ }
if f.largeFrame && sentPixels && request[0] != 0 {
// Keep the synthetic desktop still after its first complete frame.
time.Sleep(50 * time.Millisecond)
@@ -207,6 +232,58 @@ func (f *rfbFixture) serve(conn io.ReadWriteCloser) {
}
}
+func TestRFBCursorWatchSharesConnectionWithoutAcknowledgingAnOldFrame(t *testing.T) {
+ allowFrame := make(chan struct{})
+ fixture := &rfbFixture{cursorBeforeFrame: true, cursorSent: make(chan struct{}), continueFrame: allowFrame}
+ local, remote := net.Pipe()
+ defer remote.Close()
+ go fixture.serve(remote)
+ client, err := newRFB(t.Context(), local)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.close()
+ cursors := make(chan *Cursor, 1)
+ client.setCursorObserver(func(cursor *Cursor) { cursors <- cursor })
+ completed := make(chan error, 1)
+ go func() {
+ img, _, err := client.capture(t.Context())
+ if err == nil && img.Bounds() != image.Rect(0, 0, 2, 2) {
+ err = fmt.Errorf("incomplete screenshot")
+ }
+ completed <- err
+ }()
+ select {
+ case <-cursors:
+ case <-time.After(2 * time.Second):
+ close(allowFrame)
+ t.Fatal("cursor was not forwarded while awaiting screenshot pixels")
+ }
+ select {
+ case err := <-completed:
+ close(allowFrame)
+ t.Fatalf("cursor-only reply falsely completed screenshot: %v", err)
+ case <-time.After(30 * time.Millisecond):
+ }
+ close(allowFrame)
+ if err := <-completed; err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(120 * time.Millisecond)
+ fixture.mu.Lock()
+ requests := fixture.frameRequests
+ fixture.mu.Unlock()
+ time.Sleep(120 * time.Millisecond)
+ fixture.mu.Lock()
+ defer fixture.mu.Unlock()
+ if fixture.frameRequests != requests {
+ t.Fatal("idle cursor watcher polled for unchanged frames")
+ }
+ if len(fixture.inputs) != 0 {
+ t.Fatal("cursor observer sent mouse or keyboard input")
+ }
+}
+
func TestDesktopPixelFormatUsesNetworkByteOrder(t *testing.T) {
var wire bytes.Buffer
if err := setDesktopPixelFormat(&wire); err != nil {
@@ -350,6 +427,61 @@ func TestLiveKeystrokesDoNotWaitForGeometryOrReplaceClipboard(t *testing.T) {
}
}
+func TestHumanMotionReusesRecentGeometryButReconcilesButtonChangesAndIdle(t *testing.T) {
+ local, remote := net.Pipe()
+ fixture := &rfbFixture{}
+ go fixture.serve(remote)
+ client, err := newRFB(t.Context(), local)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.close()
+ if _, err := client.refreshGeometry(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ requests := func() int { fixture.mu.Lock(); defer fixture.mu.Unlock(); return fixture.frameRequests }
+ before := requests()
+ x, y := 1, 1
+ for range 20 {
+ if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if requests() != before {
+ t.Fatal("ordinary motion waited for an extra video round trip")
+ }
+ if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if requests() != before+1 {
+ t.Fatal("mouse down did not reconcile current geometry")
+ }
+ if err := client.apply(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if requests() != before+1 {
+ t.Fatal("continuing a drag waited for another frame")
+ }
+ if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 0}); err != nil {
+ t.Fatal(err)
+ }
+ if requests() != before+2 {
+ t.Fatal("mouse up lost its geometry boundary")
+ }
+ client.mu.Lock()
+ client.observedAt = time.Now().Add(-time.Second)
+ client.mu.Unlock()
+ if _, err := client.inputGeometry(t.Context(), Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}); err != nil {
+ t.Fatal(err)
+ }
+ if requests() != before+3 {
+ t.Fatal("stale geometry was reused after idle")
+ }
+}
+
func TestRFBCaptureResizeColorsAndReleasedInput(t *testing.T) {
local, remote := net.Pipe()
fixture := &rfbFixture{resize: true}
diff --git a/daemon/internal/surface/rfb_updates.go b/daemon/internal/surface/rfb_updates.go
new file mode 100644
index 0000000..b3612dd
--- /dev/null
+++ b/daemon/internal/surface/rfb_updates.go
@@ -0,0 +1,112 @@
+package surface
+
+import (
+ "context"
+ "image"
+ "math/bits"
+ "time"
+)
+
+// One explicit observation shares the controller connection with a pending
+// incremental 1px request for cursor changes. A cursor-only reply cannot satisfy
+// a screenshot or geometry check. Coverage also handles tiled framebuffer replies.
+type frameObservation struct {
+ full bool
+ size Geometry
+ sent bool
+ remaining int
+ covered []uint64
+}
+
+func (o *frameObservation) reset(size Geometry) {
+ o.size = size
+ o.sent = false
+ o.remaining = 1
+ if o.full {
+ o.remaining = size.Width * size.Height
+ }
+ o.covered = make([]uint64, (o.remaining+63)/64)
+}
+
+func (o *frameObservation) cover(rect image.Rectangle) {
+ if !o.sent || o.remaining == 0 {
+ return
+ }
+ width, height := 1, 1
+ if o.full {
+ width, height = o.size.Width, o.size.Height
+ }
+ rect = rect.Intersect(image.Rect(0, 0, width, height))
+ for y := rect.Min.Y; y < rect.Max.Y; y++ {
+ start, end := y*width+rect.Min.X, y*width+rect.Max.X
+ for start < end {
+ word, offset := start/64, start%64
+ n := min(64-offset, end-start)
+ mask := (^uint64(0) >> uint(64-n)) << uint(offset)
+ o.remaining -= bits.OnesCount64(mask &^ o.covered[word])
+ o.covered[word] |= mask
+ start += n
+ }
+ }
+}
+
+func (r *rfbClient) wakeUpdates() {
+ select {
+ case r.updateWake <- struct{}{}:
+ default:
+ }
+}
+
+// A pending incremental request sleeps at the VNC server until its cursor (or
+// that single pixel) changes. No idle polling, full second video stream, or extra
+// pointer events. Explicit captures take priority and wake a held request.
+func (r *rfbClient) requestUpdates() {
+ var nextCursorAt time.Time
+ for {
+ select {
+ case <-r.done:
+ return
+ case <-r.updateWake:
+ }
+ r.mu.Lock()
+ o := r.observation
+ watch := o == nil && r.onCursor != nil && !r.cursorPending
+ if watch && time.Now().Before(nextCursorAt) {
+ r.mu.Unlock()
+ timer := time.NewTimer(time.Until(nextCursorAt))
+ select {
+ case <-r.done:
+ timer.Stop()
+ return
+ case <-r.updateWake:
+ timer.Stop()
+ case <-timer.C:
+ }
+ r.wakeUpdates()
+ continue
+ }
+ width, height := uint16(1), uint16(1)
+ send := watch
+ if o != nil && !o.sent {
+ o.sent = true
+ send = true
+ if o.full {
+ width, height = uint16(o.size.Width), uint16(o.size.Height)
+ }
+ }
+ if watch {
+ r.cursorPending = true
+ nextCursorAt = time.Now().Add(50 * time.Millisecond)
+ }
+ r.mu.Unlock()
+ if send {
+ err := r.withWrite(context.Background(), func() error {
+ return r.client.FramebufferUpdateRequest(watch, 0, 0, width, height)
+ })
+ if err != nil {
+ r.close()
+ return
+ }
+ }
+ }
+}
diff --git a/daemon/internal/surface/service.go b/daemon/internal/surface/service.go
index 87ee18c..546be06 100644
--- a/daemon/internal/surface/service.go
+++ b/daemon/internal/surface/service.go
@@ -140,6 +140,10 @@ func (s *Service) Snapshot() Snapshot {
v := *out.Geometry
out.Geometry = &v
}
+ if out.Cursor != nil {
+ v := *out.Cursor
+ out.Cursor = &v
+ }
return out
}
func (s *Service) Configure(p Provider) error {
@@ -163,6 +167,7 @@ func (s *Service) configureProvider(p Provider) {
}
s.snapshot.Controller = nil
s.snapshot.Geometry = nil
+ s.snapshot.Cursor = nil
s.snapshot.State = "disconnected"
s.snapshot.Error = nil
expires := p.ExpiresAt()
@@ -183,6 +188,7 @@ func (s *Service) configureProvider(p Provider) {
cancel()
_ = old.Close()
}
+ s.observeProviderCursor(p)
}
func (s *Service) Refresh(ctx context.Context) (Snapshot, error) {
@@ -650,6 +656,9 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re
}
s.operation.Lock()
defer s.operation.Unlock()
+ if err := ctx.Err(); err != nil {
+ return Receipt{}, err
+ }
s.mu.Lock()
session, err := s.sessionLocked(req.SessionID, actor)
if err != nil {
@@ -688,7 +697,15 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re
session.lastSeen = s.now()
s.mu.Unlock()
if spatial(req.Action.Kind) {
- current, err := p.Connect(ctx)
+ var current Geometry
+ var err error
+ if live, ok := p.(interface {
+ InputGeometry(context.Context, Action) (Geometry, error)
+ }); ok && actor.Kind == "user" {
+ current, err = live.InputGeometry(ctx, req.Action)
+ } else {
+ current, err = p.Connect(ctx)
+ }
if err != nil {
return Receipt{}, err
}
@@ -728,10 +745,22 @@ func (s *Service) Apply(ctx context.Context, actor Actor, req ActionRequest) (Re
if err := s.db.SurfacePut("surface_receipt", rec.ID, rec); err != nil {
return Receipt{}, err
}
+ // A cancelled request waiting for geometry/storage must never later click.
+ if err := ctx.Err(); err != nil {
+ rec.Status = "cancelled"
+ rec.Error = asError(err)
+ _ = s.db.SurfacePut("surface_receipt", rec.ID, rec)
+ return rec.Receipt, nil
+ }
text, err := p.Apply(ctx, req.Action)
if err != nil {
rec.Status = "outcome_unknown"
rec.Error = asError(err)
+ // A cancelled drag can have reached the desktop even if its response did
+ // not. Release within the same serial operation using a fresh deadline.
+ releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ _ = p.Release(releaseCtx)
+ cancel()
} else {
rec.Status = "dispatched"
}
diff --git a/daemon/internal/surface/service_test.go b/daemon/internal/surface/service_test.go
index 8a49819..6168278 100644
--- a/daemon/internal/surface/service_test.go
+++ b/daemon/internal/surface/service_test.go
@@ -343,6 +343,44 @@ func TestDesktopUnknownOutcomeIsNeverReplayed(t *testing.T) {
}
}
+func TestCancelledDesktopActionDoesNotDispatchAndUnknownDragReleases(t *testing.T) {
+ s, p, _ := testService(t)
+ actor := Actor{Kind: "user", Name: "Test"}
+ opened := mustOpen(t, s, actor, "control")
+ x, y := 1, 1
+ request := ActionRequest{RequestID: newID(), SessionID: opened.ID, ControlGeneration: opened.Controller.Generation,
+ GeometryRevision: s.Snapshot().Geometry.Revision, Action: Action{Kind: "pointer", X: &x, Y: &y, Buttons: 1}}
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if _, err := s.Apply(ctx, actor, request); !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancelled input: %v", err)
+ }
+ p.mu.Lock()
+ if p.applied != 0 {
+ t.Fatal("a cancelled queued action reached the provider")
+ }
+ p.failure = context.DeadlineExceeded
+ before := p.released
+ p.mu.Unlock()
+ receipt, err := s.Apply(t.Context(), actor, request)
+ if err != nil || receipt.Status != "outcome_unknown" {
+ t.Fatalf("unknown drag outcome: %v %v", receipt, err)
+ }
+ p.mu.Lock()
+ if p.released != before+1 {
+ t.Fatal("uncertain drag left buttons held")
+ }
+ p.mu.Unlock()
+ if _, err := s.Apply(t.Context(), actor, request); err != nil {
+ t.Fatal(err)
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.applied != 1 {
+ t.Fatal("the uncertain drag was replayed")
+ }
+}
+
func TestDesktopInputReceiptsOnlyBroadcastChangedStatus(t *testing.T) {
s, p, _ := testService(t)
user := Actor{Kind: "user"}
diff --git a/daemon/internal/surface/testdata/linux/display.py b/daemon/internal/surface/testdata/linux/display.py
index 11fce30..04d0f82 100644
--- a/daemon/internal/surface/testdata/linux/display.py
+++ b/daemon/internal/surface/testdata/linux/display.py
@@ -21,8 +21,12 @@
text = tk.Text(root, font=("monospace", 20), background="#252525", foreground="white")
text.place(x=100, y=100, width=1200, height=700)
text.focus_force()
+for x, label, cursor in [(100, "Clickable", "hand2"), (500, "Busy", "watch"), (900, "Resize", "sb_h_double_arrow")]:
+ target = tk.Label(root, text=label, cursor=cursor, font=("monospace", 20), background="#323232", foreground="white")
+ target.place(x=x, y=840, width=280, height=100)
lock = threading.Lock()
-state = {"fixture": "mindwire-linux-desktop-v1", "ready": False, "pointer": [0, 0], "presses": [], "keys": [], "text": ""}
+state = {"fixture": "mindwire-linux-desktop-v1", "ready": False, "pointer": [0, 0], "presses": [], "releases": [],
+ "pressPositions": [], "releasePositions": [], "keys": [], "text": ""}
def pointer(event):
@@ -30,6 +34,10 @@ def pointer(event):
state["pointer"] = [event.x_root, event.y_root]
if event.type == tk.EventType.ButtonPress:
state["presses"].append(event.num)
+ state["pressPositions"].append([event.num, event.x_root, event.y_root])
+ elif event.type == tk.EventType.ButtonRelease:
+ state["releases"].append(event.num)
+ state["releasePositions"].append([event.num, event.x_root, event.y_root])
def key(event):
@@ -46,6 +54,7 @@ def update():
root.bind_all("", pointer)
root.bind_all("", pointer)
+root.bind_all("", pointer)
root.bind_all("", key)
root.after(100, update)
diff --git a/daemon/internal/surface/types.go b/daemon/internal/surface/types.go
index b2ae2d7..6b463a9 100644
--- a/daemon/internal/surface/types.go
+++ b/daemon/internal/surface/types.go
@@ -81,6 +81,7 @@ type Snapshot struct {
ObservedAt *time.Time `json:"observedAt,omitempty"`
ProviderStatus
Geometry *Geometry `json:"geometry,omitempty"`
+ Cursor *Cursor `json:"cursor,omitempty"`
Controller *Controller `json:"controller,omitempty"`
AuthorizationExpiresAt *time.Time `json:"authorizationExpiresAt,omitempty"`
Error *Error `json:"error,omitempty"`
diff --git a/daemon/internal/toolchain/admission_test.go b/daemon/internal/toolchain/admission_test.go
index aaac5ed..7c21cb2 100644
--- a/daemon/internal/toolchain/admission_test.go
+++ b/daemon/internal/toolchain/admission_test.go
@@ -3,6 +3,7 @@ package toolchain
import (
"context"
"os"
+ "strings"
"testing"
)
@@ -24,3 +25,44 @@ func TestAdmissionDetectsBinaryChangedOutsideDaemonDespiteCachedReadiness(t *tes
t.Fatal("admission reused readiness from a different binary")
}
}
+
+func TestCancelledVersionProbeIsNotCachedAsAVersionMismatch(t *testing.T) {
+ m, spec := fixtureManager(t)
+ m.install = fakeInstall
+ if _, err := m.Install(context.Background(), spec, false); err != nil {
+ t.Fatal(err)
+ }
+ m.invalidate(spec)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ failed := m.Software(ctx, spec)
+ if failed.Compatibility != "unavailable" || strings.Contains(failed.Message, "installation changed") {
+ t.Fatalf("a cancelled probe was presented as corruption: %+v", failed)
+ }
+ if ready := m.Software(context.Background(), spec); ready.Compatibility != "supported" {
+ t.Fatalf("a transient probe poisoned readiness: %+v", ready)
+ }
+}
+
+func TestExplicitRepairCanReplaceDamagedManagedVersionWithoutTouchingExternalCLI(t *testing.T) {
+ m, spec := fixtureManager(t)
+ m.install = fakeInstall
+ version, err := m.Install(context.Background(), spec, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(versionBinary(m.root, spec.Binary, version), []byte("#!/bin/sh\necho 9.0.0\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ m.invalidate(spec)
+ software := m.Software(context.Background(), spec)
+ if !software.RepairAvailable || !software.UpdateAvailable {
+ t.Fatalf("repair is inaccessible: %+v", software)
+ }
+ if _, err := m.Install(context.Background(), spec, true); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.CheckAdmission(context.Background(), spec); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/daemon/internal/toolchain/catalog.go b/daemon/internal/toolchain/catalog.go
index d9ace1c..09dd62d 100644
--- a/daemon/internal/toolchain/catalog.go
+++ b/daemon/internal/toolchain/catalog.go
@@ -120,9 +120,10 @@ type Software struct {
InstalledVersion string `json:"installedVersion"`
RecommendedVersion string `json:"recommendedVersion,omitempty"`
LatestVersion string `json:"latestVersion,omitempty"` // latest approved catalog entry, not npm latest
- Compatibility string `json:"compatibility"` // supported | untested | incompatible | not_installed
+ Compatibility string `json:"compatibility"` // supported | untested | incompatible | unavailable | not_installed
Managed bool `json:"managed"`
UpdateAvailable bool `json:"updateAvailable"`
+ RepairAvailable bool `json:"repairAvailable,omitempty"`
RequiresDaemonUpdate bool `json:"requiresDaemonUpdate"`
RequiredDaemonVersion string `json:"requiredDaemonVersion,omitempty"`
Message string `json:"message,omitempty"`
diff --git a/daemon/internal/toolchain/exec.go b/daemon/internal/toolchain/exec.go
index 95c6da3..7804ccf 100644
--- a/daemon/internal/toolchain/exec.go
+++ b/daemon/internal/toolchain/exec.go
@@ -109,9 +109,15 @@ func validEnvKey(key string) bool {
return true
}
-// Shell restores managed paths AFTER the login shell has sourced its startup files. Merely
-// setting cmd.Env's PATH lets macOS path_helper choose a different, globally installed CLI.
+// Shell restores the launching account's PATH after login startup. Linux's
+// /etc/profile can replace it entirely; macOS path_helper can reorder it. Keep
+// native tool discovery consistent with CommandContext, retain additional login
+// paths, then put explicit managed selections ahead of both.
func Shell(script string) string {
+ prefix := ""
+ if inherited := os.Getenv("PATH"); inherited != "" {
+ prefix = "export PATH=" + quote(inherited) + ":\"$PATH\"; "
+ }
var bins []string
var env []string
var pins []string
@@ -126,12 +132,12 @@ func Shell(script string) string {
}
}
if len(bins) == 0 {
- return script
+ return prefix + script
}
sort.Strings(bins)
sort.Strings(env)
sort.Strings(pins)
- prefix := "export PATH=" + quote(strings.Join(bins, string(os.PathListSeparator))) + ":\"$PATH\"; "
+ prefix += "export PATH=" + quote(strings.Join(bins, string(os.PathListSeparator))) + ":\"$PATH\"; "
prefix += "shopt -u checkhash; " + strings.Join(pins, "; ") + "; "
if len(env) > 0 {
prefix += strings.Join(env, "; ") + "; "
diff --git a/daemon/internal/toolchain/exec_test.go b/daemon/internal/toolchain/exec_test.go
new file mode 100644
index 0000000..caf8284
--- /dev/null
+++ b/daemon/internal/toolchain/exec_test.go
@@ -0,0 +1,39 @@
+package toolchain
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestShellPreservesNativeToolsWhenLoginReplacesPath(t *testing.T) {
+ bash, err := exec.LookPath("bash")
+ if err != nil {
+ t.Skip("requires bash")
+ }
+ t.Setenv("MINDWIRE_TOOLCHAIN_DIR", t.TempDir())
+ native := filepath.Join(t.TempDir(), "native tools ' $literal")
+ login := filepath.Join(t.TempDir(), "login tools")
+ for dir, tools := range map[string]map[string]string{
+ native: {"mindwire-path-probe": "native"},
+ login: {"mindwire-path-probe": "wrong-version", "mindwire-login-probe": "login"},
+ } {
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for name, output := range tools {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\nprintf '%s\\n' "+quote(output)+"\n"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ t.Setenv("PATH", native+string(os.PathListSeparator)+os.Getenv("PATH"))
+ // Simulate /etc/profile replacing PATH before the generated command runs.
+ script := "export PATH=" + quote(login) + ":/usr/bin:/bin; " + Shell("mindwire-path-probe && mindwire-login-probe")
+ output, err := exec.Command(bash, "-c", script).CombinedOutput()
+ if err != nil || strings.TrimSpace(string(output)) != "native\nlogin" {
+ t.Fatalf("native/login tools changed or disappeared: %q %v", output, err)
+ }
+}
diff --git a/daemon/internal/toolchain/manager.go b/daemon/internal/toolchain/manager.go
index 629a386..3c08889 100644
--- a/daemon/internal/toolchain/manager.go
+++ b/daemon/internal/toolchain/manager.go
@@ -38,6 +38,7 @@ type probe struct {
raw string
version string
selected string
+ err error
at time.Time
}
@@ -63,10 +64,17 @@ func (m *Manager) Software(ctx context.Context, spec Spec) Software {
s := c.Evaluate(spec.ID, m.version, installed)
s.Managed = p.selected != ""
s.CatalogSource, s.CatalogStale = source, stale
- if s.Managed && p.version != p.selected {
+ if p.err != nil && s.Managed {
+ s.Compatibility = "unavailable"
+ s.Message = fmt.Sprintf("Could not start the managed %s CLI to check its version. Try checking again, or repair the CLI in agent settings.", spec.Name)
+ s.RepairAvailable = s.RecommendedVersion != ""
+ } else if s.Managed && p.version != p.selected {
s.Compatibility = "incompatible"
- s.Message = "The managed CLI no longer matches its selected version. Reinstall the supported version."
+ s.Message = fmt.Sprintf("The managed %s installation changed (selected %s, found %s). Repair the CLI in agent settings.", spec.Name, p.selected, installed)
+ s.RepairAvailable = s.RecommendedVersion != ""
}
+ // Older clients can still offer the existing explicit update action for repairs.
+ s.UpdateAvailable = s.UpdateAvailable || s.RepairAvailable
return s
}
@@ -84,7 +92,7 @@ func (m *Manager) RefreshSoftware(ctx context.Context, spec Spec, force bool) So
func (m *Manager) CheckAdmission(ctx context.Context, spec Spec) error {
m.invalidate(spec)
info := m.Software(ctx, spec)
- if info.Compatibility == "incompatible" {
+ if info.Compatibility == "incompatible" || info.Compatibility == "unavailable" {
return fmt.Errorf("%s", info.Message)
}
return nil
@@ -100,15 +108,15 @@ func (m *Manager) probe(ctx context.Context, spec Spec) probe {
selectedVersion := selected(m.root, spec.Binary)
m.mu.Lock()
defer m.mu.Unlock()
- if p, ok := m.probes[spec.Binary]; ok && p.selected == selectedVersion && time.Since(p.at) < 30*time.Second {
+ if p, ok := m.probes[spec.Binary]; ok && p.err == nil && p.selected == selectedVersion && time.Since(p.at) < 30*time.Second {
return p
}
path := spec.Binary
if selectedVersion != "" {
path = versionBinary(m.root, spec.Binary, selectedVersion)
}
- raw, _ := versionOutput(ctx, spec, path)
- p := probe{raw: raw, version: ParseVersion(raw), selected: selectedVersion, at: time.Now()}
+ raw, err := versionOutput(ctx, spec, path)
+ p := probe{raw: raw, version: ParseVersion(raw), selected: selectedVersion, err: err, at: time.Now()}
m.probes[spec.Binary] = p
return p
}
@@ -138,7 +146,7 @@ func versionOutput(ctx context.Context, spec Spec, path string) (string, error)
// A known incompatible version is rejected before it can start another turn.
func (m *Manager) Check(ctx context.Context, spec Spec) (string, error) {
s := m.Software(ctx, spec)
- if s.Compatibility == "incompatible" {
+ if s.Compatibility == "incompatible" || s.Compatibility == "unavailable" {
return "", fmt.Errorf("%s", s.Message)
}
if s.InstalledVersion == "" {
@@ -157,7 +165,7 @@ func (m *Manager) Install(ctx context.Context, spec Spec, update bool) (string,
m.invalidate(spec)
s := m.RefreshSoftware(ctx, spec, update)
if !update && s.InstalledVersion != "" {
- if s.Compatibility == "incompatible" {
+ if s.Compatibility == "incompatible" || s.Compatibility == "unavailable" {
return "", fmt.Errorf("%s", s.Message)
}
return s.InstalledVersion, nil
@@ -169,7 +177,7 @@ func (m *Manager) Install(ctx context.Context, spec Spec, update bool) (string,
}
return "", fmt.Errorf("no tested %s version is available for this Mindwire service", spec.Name)
}
- if s.InstalledVersion != "" && (!validVersion(s.InstalledVersion) || compare(s.InstalledVersion, target) > 0) {
+ if s.InstalledVersion != "" && !s.RepairAvailable && (!validVersion(s.InstalledVersion) || compare(s.InstalledVersion, target) > 0) {
return "", fmt.Errorf("installed %s is newer than the tested version %s; it was left unchanged", spec.Name, target)
}
if s.InstalledVersion == target && s.Compatibility == "supported" {
diff --git a/daemon/openapi.json b/daemon/openapi.json
index 8371d0b..eb15798 100644
--- a/daemon/openapi.json
+++ b/daemon/openapi.json
@@ -287,6 +287,10 @@
"localDesktopVersion": {
"type": "integer",
"description": "Opt-in Mac desktop through paired SSH; 1 on supported personal computers."
+ },
+ "projectLibraryVersion": {
+ "type": "integer",
+ "description": "1 supports durable project folders, membership and custom ordering."
}
},
"required": [
@@ -3019,6 +3023,10 @@
}
}
}
+ },
+ "projectLibrary": {
+ "$ref": "#/components/schemas/ProjectLibrary",
+ "description": "Present in full snapshots and when organization changed since the requested revision."
}
}
},
@@ -4076,7 +4084,7 @@
},
"compatibility": {
"type": "string",
- "description": "supported, untested, incompatible, or not_installed. Unknown versions are untested, never implicitly approved for installation."
+ "description": "supported, untested, incompatible, unavailable, or not_installed. Unavailable means a managed CLI version probe failed; retry or explicitly repair it. Unknown versions are untested, never implicitly approved for installation."
},
"requiredDaemonVersion": {
"type": "string"
@@ -4105,6 +4113,10 @@
"type": "string"
},
"description": "Missing shared tools. Run setup to repair them even when the CLI is already installed."
+ },
+ "repairAvailable": {
+ "type": "boolean",
+ "description": "An explicit update can repair a changed or unreadable managed CLI. Also sets updateAvailable for older clients."
}
}
},
@@ -6197,6 +6209,133 @@
},
"required": [],
"additionalProperties": false
+ },
+ "ProjectFolder": {
+ "type": "object",
+ "required": [
+ "id",
+ "name"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80
+ }
+ }
+ },
+ "ProjectLibrary": {
+ "type": "object",
+ "required": [
+ "version",
+ "revision",
+ "folders",
+ "membership",
+ "order"
+ ],
+ "properties": {
+ "version": {
+ "type": "integer",
+ "enum": [
+ 1
+ ]
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "folders": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProjectFolder"
+ },
+ "description": "Folders in their display order."
+ },
+ "membership": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ },
+ "description": "Project ID to folder ID. Only registered projects are included."
+ },
+ "order": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Custom project order, using registry project IDs."
+ }
+ }
+ },
+ "ProjectPlacement": {
+ "type": "object",
+ "required": [
+ "projectId",
+ "folderId"
+ ],
+ "properties": {
+ "projectId": {
+ "type": "string"
+ },
+ "folderId": {
+ "type": "string",
+ "nullable": true,
+ "description": "Null removes the project from its folder without changing its files."
+ }
+ }
+ },
+ "ProjectLibraryEdit": {
+ "type": "object",
+ "required": [
+ "expectedRevision"
+ ],
+ "properties": {
+ "expectedRevision": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The current project library revision, not a project record revision."
+ },
+ "folders": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProjectFolder"
+ },
+ "description": "Create or rename folders. Deleted IDs cannot be reused."
+ },
+ "deleteFolders": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "placements": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProjectPlacement"
+ }
+ },
+ "order": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Reorder these projects while retaining other projects in their existing slots."
+ },
+ "folderOrder": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Reorder these folders while retaining the others."
+ },
+ "importIfEmpty": {
+ "type": "boolean",
+ "description": "One-time migration: ignored once this workspace has any library revision."
+ }
+ }
}
}
},
@@ -17010,6 +17149,141 @@
}
]
}
+ },
+ "/workspace/project-library": {
+ "get": {
+ "tags": [
+ "Workspace"
+ ],
+ "summary": "Read project folders and order",
+ "description": "Reads only organization metadata. No native history scan or filesystem move.",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Authoritative project library.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProjectLibrary"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Workspace authentication required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Workspace"
+ ],
+ "summary": "Edit project organization atomically",
+ "description": "Partial conditional edit in a single SQLite transaction. Identical retries do not advance the revision. Deleting folders keeps all projects, files and conversations. Requires projectLibraryVersion >= 1.",
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProjectLibraryEdit"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Authoritative project library.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProjectLibrary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid folder, name or project reference. Nothing was changed.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Workspace authentication required.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Organization changed on another client. Fetch it and rebase the intended edit.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "410": {
+ "description": "A folder was deleted. Do not recreate that ID.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
diff --git a/daemon/sdk/mindwire.go b/daemon/sdk/mindwire.go
index 2c7f2de..6da0b43 100644
--- a/daemon/sdk/mindwire.go
+++ b/daemon/sdk/mindwire.go
@@ -261,6 +261,7 @@ type Health struct {
Agent string `json:"agent"`
Version string `json:"version"`
WorkspaceMetadataVersion int `json:"workspaceMetadataVersion"`
+ ProjectLibraryVersion int `json:"projectLibraryVersion"`
ProjectOperationsVersion int `json:"projectOperationsVersion"`
ProjectIconsVersion int `json:"projectIconsVersion"`
ProjectSyncVersion int `json:"projectSyncVersion"`
@@ -284,7 +285,7 @@ func (c *Client) Health() Health {
if runtime.GOOS == "darwin" && os.Geteuid() != 0 {
localDesktopVersion = surface.LocalDesktopVersion
}
- return Health{OK: true, Agent: c.core.sup.Default(), Version: agent.Version, WorkspaceMetadataVersion: registry.Version, ProjectOperationsVersion: registry.ProjectOperationsVersion, ProjectIconsVersion: projecticon.Version, ProjectSyncVersion: projectsync.ProtocolVersion(), ConversationBrowserVersion: conversations.BrowserVersion, SurfaceProtocolVersion: surface.Version, LocalDesktopVersion: localDesktopVersion, NotificationPreferencesVersion: registry.NotificationPreferencesVersion, HarnessPolicyVersion: toolchain.PolicyVersion, WorkspaceIsolationVersion: agent.WorkspaceIsolationVersion, WorkspaceIsolation: agent.WorkspaceIsolation(), WorkspaceExecutionVersion: workspaceexec.Version, TerminalProtocolVersion: workspaceexec.TerminalVersion, TurnRequestVersion: orchestrator.TurnRequestVersion, ChatForkVersion: agent.ChatForkVersion, ImageAttachmentsVersion: agent.ImageAttachmentsVersion}
+ return Health{OK: true, Agent: c.core.sup.Default(), Version: agent.Version, WorkspaceMetadataVersion: registry.Version, ProjectLibraryVersion: registry.ProjectLibraryVersion, ProjectOperationsVersion: registry.ProjectOperationsVersion, ProjectIconsVersion: projecticon.Version, ProjectSyncVersion: projectsync.ProtocolVersion(), ConversationBrowserVersion: conversations.BrowserVersion, SurfaceProtocolVersion: surface.Version, LocalDesktopVersion: localDesktopVersion, NotificationPreferencesVersion: registry.NotificationPreferencesVersion, HarnessPolicyVersion: toolchain.PolicyVersion, WorkspaceIsolationVersion: agent.WorkspaceIsolationVersion, WorkspaceIsolation: agent.WorkspaceIsolation(), WorkspaceExecutionVersion: workspaceexec.Version, TerminalProtocolVersion: workspaceexec.TerminalVersion, TurnRequestVersion: orchestrator.TurnRequestVersion, ChatForkVersion: agent.ChatForkVersion, ImageAttachmentsVersion: agent.ImageAttachmentsVersion}
}
// processStarted anchors the daemon-process uptime the /stats snapshot reports; set once at package
diff --git a/daemon/sdk/mindwire_test.go b/daemon/sdk/mindwire_test.go
index 6d804ce..fe26fe7 100644
--- a/daemon/sdk/mindwire_test.go
+++ b/daemon/sdk/mindwire_test.go
@@ -695,6 +695,8 @@ func TestSDKRouteParity(t *testing.T) {
"GET /surfaces/desktop/actions/{id}": "Surfaces.Receipt",
"GET /artifacts/{id}": "Surfaces.Artifact",
"GET /workspace": "Workspace.Snapshot",
+ "GET /workspace/project-library": "Workspace.Library",
+ "PATCH /workspace/project-library": "Workspace.EditLibrary",
"GET /workspace/changes": "Workspace.Changes",
"GET /workspace/conversations": "Workspace.Conversations",
"POST /workspace/conversations/open": "Workspace.OpenConversation",
diff --git a/daemon/sdk/surfaces.go b/daemon/sdk/surfaces.go
index d279478..6f4d909 100644
--- a/daemon/sdk/surfaces.go
+++ b/daemon/sdk/surfaces.go
@@ -10,6 +10,7 @@ type SurfaceSnapshot = surface.Snapshot
type SurfaceSession = surface.Session
type SurfaceController = surface.Controller
type SurfaceGeometry = surface.Geometry
+type SurfaceCursor = surface.Cursor
type SurfaceCapabilities = surface.Capabilities
type SurfaceBinding = surface.Binding
type LocalDesktopInfo = surface.LocalDesktopInfo
diff --git a/daemon/sdk/workspace.go b/daemon/sdk/workspace.go
index fd376db..3c80363 100644
--- a/daemon/sdk/workspace.go
+++ b/daemon/sdk/workspace.go
@@ -14,14 +14,18 @@ import (
// Aliases keep the same records, revisions and deletion protocol across Go, HTTP, TypeScript and iOS.
type (
- WorkspaceRecord = registry.Record
- WorkspaceAgent = registry.Agent
- WorkspaceProject = registry.Project
- WorkspaceChat = registry.Chat
- WorkspaceDeletion = registry.Deletion
- WorkspaceImport = registry.Import
- WorkspaceSnapshot = registry.Snapshot
- ProjectIcon = projecticon.Image
+ WorkspaceRecord = registry.Record
+ WorkspaceAgent = registry.Agent
+ WorkspaceProject = registry.Project
+ WorkspaceChat = registry.Chat
+ WorkspaceDeletion = registry.Deletion
+ WorkspaceImport = registry.Import
+ WorkspaceSnapshot = registry.Snapshot
+ ProjectIcon = projecticon.Image
+ ProjectFolder = registry.ProjectFolder
+ ProjectLibrary = registry.ProjectLibrary
+ ProjectLibraryEdit = registry.ProjectLibraryEdit
+ ProjectPlacement = registry.ProjectPlacement
)
// Workspace is workspace-wide metadata; selecting a different harness never changes its scope.
@@ -69,6 +73,20 @@ func workspaceError(op string, err error) error {
type WorkspaceSyncOptions struct{ Refresh bool }
+func (w *Workspace) Library() (ProjectLibrary, error) {
+ library, err := w.c.core.registry.ProjectLibrary()
+ return library, workspaceError("Workspace.Library", err)
+}
+
+func (w *Workspace) EditLibrary(edit ProjectLibraryEdit) (ProjectLibrary, error) {
+ w.c.core.registryMu.Lock()
+ defer w.c.core.registryMu.Unlock()
+ if err := w.c.core.registry.EditProjectLibrary(edit); err != nil {
+ return ProjectLibrary{}, workspaceError("Workspace.EditLibrary", err)
+ }
+ return w.Library()
+}
+
// ProjectIcon reads a small image confined to the project's directory. An empty
// path uses the saved icon; an explicit relative path previews a candidate.
func (w *Workspace) ProjectIcon(projectID, path string) (ProjectIcon, error) {
diff --git a/daemon/sdk/workspace_test.go b/daemon/sdk/workspace_test.go
index 7299228..c31de57 100644
--- a/daemon/sdk/workspace_test.go
+++ b/daemon/sdk/workspace_test.go
@@ -13,6 +13,37 @@ import (
"time"
)
+func TestWorkspaceProjectLibrarySharesOneRegistryAcrossHarnesses(t *testing.T) {
+ c := newFakeClient(t, nil)
+ if c.Health().ProjectLibraryVersion != 1 {
+ t.Fatal("missing library capability")
+ }
+ created, err := c.Workspace.Projects.Put("project", WorkspaceProject{Name: "App", Path: t.TempDir()}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ folder := "work"
+ edit := ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Work"}},
+ Placements: []ProjectPlacement{{ProjectID: "project", FolderID: &folder}}, Order: []string{"project"}}
+ saved, err := c.Workspace.EditLibrary(edit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ other, err := c.WithAgent("codex").Workspace.Library()
+ if err != nil || !reflect.DeepEqual(other, saved) {
+ t.Fatalf("harness changed the workspace library: %+v %v", other, err)
+ }
+ delta, err := c.Workspace.Changes(created.Revision, created.WorkspaceID)
+ if err != nil || delta.ProjectLibrary == nil || !reflect.DeepEqual(*delta.ProjectLibrary, saved) {
+ t.Fatalf("SDK delta lost folders: %+v %v", delta, err)
+ }
+ _, err = c.Workspace.EditLibrary(ProjectLibraryEdit{Folders: []ProjectFolder{{ID: folder, Name: "Stale edit"}}})
+ var conflict *APIError
+ if !errors.As(err, &conflict) || conflict.Status != 409 {
+ t.Fatal("SDK did not expose a conditional-edit conflict", err)
+ }
+}
+
func TestWorkspaceDiscoversAndReadsNativeCLIConversations(t *testing.T) {
home, cwd := t.TempDir(), t.TempDir()
t.Setenv("CLAUDE_CONFIG_DIR", home)
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 7d248f9..d7953cc 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -65,7 +65,8 @@ export type { ProjectRequest, ProjectAuth, ProjectOperation, ProjectRemoveReques
GitConnection, GitAccessState, ProjectGitState, GitAction, GitIdentity, GitIdentityScope, GitIdentitySettings,
GitIdentityUpdate, GitOperationRequest, GitOperation } from "./workspace.js";
export type { WorkspaceRecord, WorkspaceAgent, WorkspaceProject, WorkspaceChat, WorkspaceKind,
- WorkspaceInput, WorkspaceImport, WorkspaceSnapshot, ProjectIcon } from "./workspace.js";
+ WorkspaceInput, WorkspaceImport, WorkspaceSnapshot, ProjectIcon,
+ ProjectFolder, ProjectLibrary, ProjectLibraryEdit } from "./workspace.js";
export * from "./surfaces.js";
export * from "./execution.js";
diff --git a/packages/sdk/src/surfaces.ts b/packages/sdk/src/surfaces.ts
index 2b6dc1f..64dd290 100644
--- a/packages/sdk/src/surfaces.ts
+++ b/packages/sdk/src/surfaces.ts
@@ -2,6 +2,10 @@ import type { Mindwire } from "./client.js";
import { readSSE } from "./sse.js";
export interface SurfaceGeometry { width: number; height: number; revision: number }
+/** Actual remote cursor. The base64 PNG changes only with its shape, not its position. */
+export interface SurfaceCursor {
+ id: string; width: number; height: number; hotspotX: number; hotspotY: number; png: string;
+}
export interface SurfaceProblem { code: string; message: string }
export interface SurfaceCapabilities {
view: boolean; capture: boolean; pointer: boolean; keyboard: boolean; text: boolean;
@@ -18,7 +22,7 @@ export interface SurfaceSnapshot {
version: number; revision: number; instanceId: string;
state: string; observedAt?: string; supported: boolean; enabled: boolean; available: boolean;
credentials: boolean; os?: string; capabilities: SurfaceCapabilities;
- geometry?: SurfaceGeometry; controller?: SurfaceController;
+ geometry?: SurfaceGeometry; cursor?: SurfaceCursor; controller?: SurfaceController;
authorizationExpiresAt?: string; error?: SurfaceProblem;
setup?: { reason: string; command: string };
}
@@ -57,7 +61,7 @@ export interface SurfaceActionRequest {
}
export interface SurfaceReceipt {
id: string; sessionId: string; surfaceId: string; kind: string;
- status: "dispatching" | "dispatched" | "outcome_unknown"; createdAt: string; error?: SurfaceProblem;
+ status: "dispatching" | "dispatched" | "outcome_unknown" | "cancelled"; createdAt: string; error?: SurfaceProblem;
/** Transient clipboard output, not stored in the receipt. */
text?: string;
}
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 43653bc..8f1e0f3 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -954,10 +954,12 @@ export interface HarnessSoftware {
installedVersion: string;
recommendedVersion?: string;
latestVersion?: string;
- compatibility: "supported" | "untested" | "incompatible" | "not_installed" | (string & {});
+ compatibility: "supported" | "untested" | "incompatible" | "unavailable" | "not_installed" | (string & {});
managed: boolean;
updateAvailable: boolean;
requiresDaemonUpdate: boolean;
+ /** An explicitly requested update can repair this damaged managed installation. */
+ repairAvailable?: boolean;
requiredDaemonVersion?: string;
message?: string;
catalogRevision: number;
@@ -1133,6 +1135,8 @@ export interface Health {
localDesktopVersion?: number;
/** Workspace registry protocol version; absent on daemons predating workspace metadata. */
workspaceMetadataVersion?: number;
+ /** Durable project folders, membership and ordering, with conditional edits. */
+ projectLibraryVersion?: number;
/** Durable project creation/clone operations; absent on older daemons. */
projectOperationsVersion?: number;
/** Project-relative icons and authenticated image previews. */
diff --git a/packages/sdk/src/workspace.ts b/packages/sdk/src/workspace.ts
index df51bdf..4acbe4c 100644
--- a/packages/sdk/src/workspace.ts
+++ b/packages/sdk/src/workspace.ts
@@ -246,6 +246,29 @@ export interface WorkspaceImport {
chats?: (WorkspaceInput & { id: string })[];
}
+export interface ProjectFolder { id: string; name: string }
+
+/** Daemon-owned grouping and manual order; all IDs refer to this workspace. */
+export interface ProjectLibrary {
+ version: number;
+ revision: number;
+ folders: ProjectFolder[];
+ membership: Record;
+ order: string[];
+}
+
+/** Partial atomic edit. On 409, read the current library before deciding how to rebase. */
+export interface ProjectLibraryEdit {
+ expectedRevision: number;
+ folders?: ProjectFolder[];
+ deleteFolders?: string[];
+ placements?: { projectId: string; folderId: string | null }[];
+ order?: string[];
+ folderOrder?: string[];
+ /** Migration only: existing daemon organization always wins. */
+ importIfEmpty?: boolean;
+}
+
export interface WorkspaceSnapshot {
version: number;
workspaceId: string;
@@ -258,6 +281,8 @@ export interface WorkspaceSnapshot {
deleted: { kind: WorkspaceKind; id: string; revision: number }[];
/** Cached chats are retained when a harness's native list could not be refreshed. */
sessionDiscoveryIssues?: { projectId: string; agent: string; message: string }[];
+ /** Present on full snapshots, or when organization changed in a delta. */
+ projectLibrary?: ProjectLibrary;
}
export class WorkspaceCollection {
@@ -304,6 +329,14 @@ export class WorkspaceApi {
return this.mw.http.request("GET", "/workspace", { query: options });
}
+ library(): Promise {
+ return this.mw.http.request("GET", "/workspace/project-library");
+ }
+
+ editLibrary(edit: ProjectLibraryEdit): Promise {
+ return this.mw.http.request("PATCH", "/workspace/project-library", { body: edit });
+ }
+
/** Read the saved icon, or preview a candidate relative path within the project. Requires projectIconsVersion >= 1. */
projectIcon(projectId: string, path?: string): Promise {
return this.mw.http.request("GET", `/workspace/projects/${encodeURIComponent(projectId)}/icon`, { query: { path } });
diff --git a/packages/sdk/test/workspace-live.test.ts b/packages/sdk/test/workspace-live.test.ts
index 8ad6355..7ab5e9c 100644
--- a/packages/sdk/test/workspace-live.test.ts
+++ b/packages/sdk/test/workspace-live.test.ts
@@ -53,10 +53,17 @@ test.skipIf(!process.env.MINDWIRE_TEST_DAEMON)("workspace SDK survives daemon re
expect(exitCode).not.toBe(0);
expect(readFileSync(join(directory, "daemon.token"), "utf8")).toBe(token);
expect(statSync(join(directory, "daemon.token")).mode & 0o777).toBe(0o600);
+ expect((await a.health()).projectLibraryVersion).toBe(1);
+ const libraryEdit = { expectedRevision: 0, folders: [{ id: "work", name: "Work" }],
+ placements: [{ projectId: "project", folderId: "work" }], order: ["project"] };
+ const library = await a.workspace.editLibrary(libraryEdit);
+ expect(await b.workspace.library()).toEqual(library);
+ expect(await a.workspace.editLibrary(libraryEdit)).toEqual(library);
await stop(child);
child = start();
await ready();
- expect(await b.workspace.snapshot()).toEqual(created);
+ expect(await b.workspace.snapshot()).toEqual({ ...created, revision: library.revision, projectLibrary: library });
+ expect(await b.workspace.library()).toEqual(library);
const original = created.projects[0]!;
const edits = await Promise.allSettled([
@@ -71,6 +78,9 @@ test.skipIf(!process.env.MINDWIRE_TEST_DAEMON)("workspace SDK survives daemon re
expect(changed.full).toBe(false);
expect(changed.projects).toHaveLength(1);
const deleted = await a.workspace.projects.delete("project", changed.projects[0]!.revision);
+ expect(deleted.projectLibrary?.membership).toEqual({});
+ expect(deleted.projectLibrary?.order).toEqual([]);
+ expect(deleted.projectLibrary?.folders).toEqual(library.folders);
expect(deleted.chats).toHaveLength(0);
expect(deleted.deleted.map(item => item.kind).sort()).toEqual(["chats", "projects"]);
const afterStaleImport = await b.workspace.import(created);
diff --git a/packages/sdk/test/workspace.test.ts b/packages/sdk/test/workspace.test.ts
index d742246..1eebb6b 100644
--- a/packages/sdk/test/workspace.test.ts
+++ b/packages/sdk/test/workspace.test.ts
@@ -1,11 +1,38 @@
import { test, expect } from "bun:test";
-import { Mindwire, ApiError, remote, ensureDaemon, type WorkspaceSnapshot, type SandboxHost } from "../src/index.js";
+import { Mindwire, ApiError, remote, ensureDaemon, type WorkspaceSnapshot, type ProjectLibrary, type ProjectLibraryEdit, type SandboxHost } from "../src/index.js";
const snapshot: WorkspaceSnapshot = {
version: 1, workspaceId: "workspace-identity", revision: 4, full: true,
agents: [], projects: [], chats: [], deleted: [],
};
+test("project folders use the workspace API with conditional edits and explicit null membership", async () => {
+ const library: ProjectLibrary = { version: 1, revision: 7, folders: [{ id: "work", name: "Work" }], membership: {}, order: ["project"] };
+ const edit: ProjectLibraryEdit = { expectedRevision: 6, placements: [{ projectId: "project", folderId: null }] };
+ const calls: { path: string; method: string; body: unknown }[] = [];
+ const mw = new Mindwire({ target: remote("http://registry"), fetch: async (input, init) => {
+ calls.push({ path: new URL(input).pathname, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : null });
+ return Response.json(new URL(input).pathname === "/workspace" ? { ...snapshot, projectLibrary: library } : library);
+ } });
+ expect(await mw.workspace.library()).toEqual(library);
+ expect(await mw.workspace.editLibrary(edit)).toEqual(library);
+ expect((await mw.workspace.snapshot()).projectLibrary).toEqual(library);
+ expect(calls.slice(0, 2)).toEqual([
+ { path: "/workspace/project-library", method: "GET", body: null },
+ { path: "/workspace/project-library", method: "PATCH", body: edit },
+ ]);
+});
+
+test("a stale library edit exposes its conflict without silently replaying it", async () => {
+ let writes = 0;
+ const mw = new Mindwire({ target: remote("http://registry"), fetch: async () => {
+ writes++;
+ return Response.json({ error: "record changed on another client" }, { status: 409 });
+ } });
+ await expect(mw.workspace.editLibrary({ expectedRevision: 1, order: ["b", "a"] })).rejects.toMatchObject({ status: 409 });
+ expect(writes).toBe(1);
+});
+
test("global conversations browse without a cwd and adopt the original reference through the shared workspace API", async () => {
const calls: { url: URL; method: string; body: unknown }[] = [];
const row = { id: "native:reference", agentType: "codex", agentName: "Codex", cwd: "/work/original folder",