feat: add Cursor Agent CLI driver (agent acp) - #2
Conversation
fix: loopback + Origin hardening, 0600 config; add dweb MCP integration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(#129) * Add peer comms approval gate and async delegate_bot handoff Bots could already consult each other synchronously via ask_bot; this adds the two missing pieces of peer collaboration: - delegate_bot: fire-and-forget handoff — the target bot picks the work up after the source turn settles, no blocking, same mirrored channel and chips for visibility - approvePeerComms: per-bot opt-in gate that turns any peer contact into an Allow/Deny/Always-allow card answered by the human first Also fixes PATCH /api/bots/:id silently dropping approvePeerComms. * Peer comms approval gate + async delegate_bot: fix the blockers, then merge Merges #128 (thanks @stephenlzc) with the fixes it needs to be safe. The design is right — the depth cap still holds, a denied ask_bot doesn't hang the caller, and async handoff never regains agents tools — but three defects would have bitten users, and two of them were found by reproducing them, not by reading. 1. The approval card was never settled. `resolvePeerComms` took an ApprovalBus it never used (`_bus`), and a harness-native card emits no `request.resolved`, so `answered`/`dismissed` stayed unset forever. The client keeps rendering an unanswered card and the composer is disabled behind it — so turning the toggle on and using it once left that bot's chat permanently unusable, and the card is persisted, so a restart didn't clear it. Cards now settle on answer AND on timeout, stale cards from a dead process are dismissed at boot, and deleting a bot cancels approvals naming it instead of making the caller wait out 15 minutes. 2. The drained delegation ran as an uncaught `void startTurn(...)`. startTurn rejects on ordinary conditions — busy target, deleted bot, unavailable provider — and an unhandled rejection is fatal on Node 24, which in the packaged app kills the harness child. Failures now land as a chip on the source thread. 3. Async removed the backpressure ask_bot got for free by making the caller wait, with nothing in its place. Queues are capped per turn, a failed or interrupted turn drops its queue (with a chip) instead of firing it later on an unrelated turn, and the busy check is re-taken after the approval await — a 15-minute-old snapshot must not start a turn on a bot that is now mid-turn, or mirror a "Messaged @x" chip for an exchange that never happens. Also: an unresolvable `fromBotId` was the cheapest way past the gate — it skipped approval AND mirroring while still running the peer turn — so it is now a 403; the channel and chips are created after the verdict, not before, so a denial leaves no trace of an exchange that didn't happen; and the tool's refusals say what to do instead of returning a bare enum. Tests: new server/peer-approval.test.ts pins the lifecycle (verified it fails without the settle fix). Suite 374 passed / 8 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Big Stephen <chicong.lian@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two places handed live credentials to anyone who asked for them. The native protocol tee wrote every provider message verbatim to ~/.openmausbot/native/<threadId>.ndjson at 0644. ACP's session/new carries mcpServers env, so the comms token and the box token were sitting in a world-readable file — the same file people attach to bug reports. It now goes through redactSecrets(), which keeps the shape (which server, which variable, how long the value was) and masks the value, and the file is written 0600. The claude driver passed the whole MCP config as an argv string, which put the Composio consumer key and the box token in `ps` output for every local user for the life of the turn. The CLI accepts a file for --mcp-config, so it now writes a 0600 temp file and removes it in settle() — including on the crash path, which is where a cleanup hung off the happy-path result would leak. The fake CLI now reads the config file back the way the real one does, so the driver tests assert on what the CLI received rather than on argv, and two of them additionally assert the secret is NOT in argv. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Harden peer delegation lifecycle
* Add shareable team manifests * Address team sharing review feedback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things silently produce an installer that builds fine and is broken in the field: a missing harness server (the mouse error page), a missing built UI (black window), and an app-update.yml that either points somewhere else or carries a publisherName the unsigned build cannot satisfy — which makes electron-updater reject every update as untrusted. None of them are checkable from the Mac the release is cut on, because the artifact carries only the .exe. Assert them on the runner, where the unpacked tree exists, and fail the build rather than ship it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the Connected Apps dialog above sticky page content, constrain it to the viewport, and add complete keyboard focus management.
* Stop handing clients the provider session cursors `resumeCursors` is the harness's own bookkeeping — the native session id to resume, per instance, per task. It goes out on every bot payload and every `bot` SSE frame, and no client has ever read it. It is harmless noise only for as long as every client is this machine. It is still worth not sending: it is internal provider state on the wire, it makes `GET /api/bots` bigger for no reason, and anything that ever consumes this API from somewhere else inherits it by default. Stripped at one chokepoint rather than at each call site, because there are nine of them and a new broadcast should not have to remember. The test asserts on the SSE bytes as well as the HTTP bodies. That is deliberate: this was found by capturing real wire output rather than by reading the code, and the wire is where it has to stay fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Strip the cursors from the standalone task responses too The bot payload went through wireBot, but POST /tasks and PATCH on a task also answer with the task record on its own, straight from the store. A renamed task carries live resume cursors, so that response leaked exactly what the rest of this change stopped sending. wireTask is now the one place a task is trimmed: wireBot's nested tasks, both botWithThread lists, and the two standalone responses. The test asserts on task.body.task and on the rename response, and deletes the bot from an outer finally so a failed assertion earlier in the test doesn't leave it in the store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* A webhook turn does not inherit auto mode Auto mode is something a person switches on for turns they are present for. A webhook turn starts with nobody at the keyboard, on a payload somebody else wrote — so inheriting that switch silently converts "I trust this bot while I'm watching" into "I trust this bot at 3am on whatever GitHub posts". Everything else about webhook triggers defends against a FORGED request, and that part is solid: hashed secret, constant-time compare, untrusted data framing, loopback by default, rate limit. This is about an authentic one. The guard standing behind auto mode is a regex list its own comment calls not a security boundary, and it must not stand in for a human. So the rule lives with the other policy in auto-approve.ts rather than as a condition at the call site: autoDecision refuses when the turn is unattended, before any allow-list is consulted, so an "always allow" grant can't widen into it either. The approval still appears in the chat and can be answered if someone is around. The mark goes on the DETACHED task's thread, since a webhook runs in its own task — marking the bot's active thread would gate the wrong conversation — and is cleared when the turn settles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Carry the unattended gate across peer-comms hops Review caught that the gate held on the bot that read the webhook payload and released on the bot that acted on it. A webhook turn starts at depth 0, so the agents tools ARE mounted and the bot can hand work to a teammate — whose turn then ran with full auto mode and every always-allow grant, nobody at the keyboard. One hop was all it took, and the depth cap does not help. The mark is now keyed by BOT rather than thread. A bot runs one turn at a time so the identity is exact, and the comms paths know who is asking but not always from which thread — ask_bot had no source thread at all. Both ask_bot and delegate_bot now pass the caller's state to the turn they start. It expires by time instead of being cleared on turn.completed. Bus subscribers fire in registration order and the delegation drain runs AFTER the main fold, so clearing there blanked the flag before the hop that needed to read it — the obvious fix, and wrong. A stale mark only ever means "ask a human", so the failure direction is safe, and the TTL stops the map growing without bound. Two wiring tests, because the existing ones exercise the rule and would all still pass if the mark were never set or never read. Both were confirmed to FAIL with the wiring removed: one deletes the gate in the fold, the other breaks only the hop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pin the ask_bot hop too Review mutated the other comms path — index.ts:124, the ask_bot one — and both existing tests stayed green. The propagation was written correctly, but nothing held it there, so a refactor could have silently reopened the hole this branch exists to close, with the suite passing. It is also the likelier path in practice: a webhook-triggered bot pulling a teammate in for an answer mid-turn is more ordinary than handing the work off asynchronously. Third test drives FAKE_ACP_MODE=ask-peer. The fake asks whichever peer list_bots returns first, so the other bots are hidden to make the target deterministic. Verified to fail with that one line mutated: "the asked teammate auto-approved — ask_bot did not carry the gate". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep unattended approvals guarded --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(effort): declare per-driver reasoning-effort levels Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * feat(effort): pass the level to Claude Code as --effort Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code * feat(effort): send the level on Codex turn/start Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * feat(effort): pass the level to Grok as --reasoning-effort * feat(effort): persist the level per bot and pass it into the turn PATCH /api/bots/:id now validates modelSelection.effort against the target instance's declared capabilities.effortLevels before it's copied through the allowlist, and startTurn hands bot.modelSelection.effort to sendTurn (cleared for cloud routines, which already borrow the instance's default model). Neither Codex nor Grok rejects an unknown level at their own protocol boundary, so this PATCH-time check is the only real gate — it's what keeps "none" (reserved for future engines) from ever reaching Claude or Codex, whose declared lists exclude it. An unavailable target instance offers an empty allowed list, so any effort is rejected: an engine that isn't there cannot promise to honour a level. The happy-path round-trip lives in server/store.test.ts instead of the HTTP suite: server/index.test.ts's fixture pins the harness to a single unknown-driver shadow instance (deliberately, for CI determinism), so no bot in that suite ever has a live target to validate an accepted level against. The API suite therefore covers the reject and no-op branches only; the accept branch is covered at the store layer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): forward effortLevels through the instance registry descriptor Task 1 added capabilities.effortLevels to ProviderAdapter, but ProviderRegistry.describe() — the method behind GET /api/instances, which is what the client actually reads — builds its own capabilities object from just computerMcp and agentsMcp and dropped the new field. Every engine's effort levels therefore came back undefined over HTTP, so Task 6's control (gated on capabilities.effortLevels.length) could never render for any bot regardless of what the client did. Forward the field the same way its two neighbors already are, with registry-level coverage via a new FakeDriverOptions.effortLevels knob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * feat(effort): add the per-bot effort control to settings Widens the client InstanceInfo.capabilities and ModelSelection types to carry effortLevels/effort, and adds effort levels to the modelSelection patch allowlist (both the local SettingsPanel patch() helper and the updateBot action) so the new segmented-button row — copied from the Computer block's pattern — can clear the field back to the engine default by sending no effort key at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): gate the level only when the target engine resolves PATCH /api/bots/:id is the app's general-purpose bot endpoint, and its callers send merged multi-field bodies: duplicateBot re-sends the source bot's whole modelSelection beside name, title and description, and updateBot debounces unrelated edits into one request. Rejecting an effort the registry could not verify therefore failed the entire request — duplicating a bot whose engine was offline lost its name, title and description with it. The gate now fires only when registry.get() actually resolves the target. An instance that isn't there promises nothing either way, and startTurn already refuses to run a turn on an unavailable instance, so an unverified level never reaches a CLI. A genuinely bad level against a live engine is still a 400. Also drops the `as EffortLevel` cast: this is the boundary that decides whether the string is a level, so it must not assert that it already is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * test(effort): replace the vacuous gate assertion with real coverage The API suite's only effort test asserted that "banana" was rejected, against a fixture whose sole instance is an unknown-driver shadow. Every level was rejected there, so the assertion passed whether the gate worked or rejected unconditionally — it could not fail. What this fixture can honestly prove is the pass-through and the store's replace semantics: a level round-trips through PATCH and GET, and re-sending the selection with the effort key dropped clears it, which is exactly the shape the panel's "Default" button sends. A comment records which branch of the gate this does not reach, so the next reader does not mistake it for full coverage; the comparison against a live engine's declared list needs a resolvable instance, which this fixture deliberately does not have. Also retitles the store's persistence test, which said "defaulting to none" while asserting undefined — the exact confusion between the explicit "none" level and no override that the level type warns about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): keep the level when the model changes within one engine pick() dispatched a bare { instanceId, model }, and both the reducer and the server's patchBot replace modelSelection wholesale, so switching Sonnet to Opus silently reset a configured xhigh back to the engine default with nothing said. The effort row just changed under the user. The selection now carries the effort across when the instance is unchanged, and drops it when the instance changes: effort vocabularies are declared per driver, so a level that survived an engine switch could easily be one the new engine never offered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): stop the panel promising a reversal Codex cannot make The comment on codex.ts's turn/start claimed null would clear a level. That was an assumption, and it is wrong. Probed against the real codex-cli 0.146.0 app-server: with the config default at "low" and the thread overridden to "high", sending effort: null emitted no thread/settings/updated and thread/resume still read back "high" — byte-identical to omitting the key. Setting a level on that same path does commit, so the null legs are genuinely inert, not merely unapplied. There is no clearing mechanism at all: "" is rejected with "reasoning_effort must not be empty", and thread/start carries no effort field. So the omit stays, and the copy changes instead. "(currently: engine default)" asserted a state we cannot deliver on a Codex thread that had already been sent a level; "(Default: no level is sent)" describes what the app actually does, which is true on all three drivers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): label X-High correctly and mark the pressed segment The row leans on `capitalize`, which renders "xhigh" as "Xhigh". Only that one level needs spelling out; the rest capitalize cleanly. The segmented buttons also carried no pressed state for assistive technology — selection was conveyed by background colour alone, while the panel's other controls already set role="switch"/aria-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * fix(effort): type the client's effort fields with EffortLevel The app declared `effort?: string` and `effortLevels?: readonly string[]`, so a typo in a dispatch reached the server and came back as a 400 rather than failing to compile. Importing a type across the boundary is already how `src/lib/notify.ts` consumes `server/notify.ts`, so this follows the existing seam rather than opening a new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP * Validate reasoning effort at runtime --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: milind-soni <milindsoni201@gmail.com>
* Run a new webhook first event * Present webhook command as a real task
* Put Grok -m after agent so ACP stdio uses the local slug. Grok 1.0.6 only applies agent flags between `agent` and `stdio`. `grok -m omlx-slug agent stdio` is accepted as a TUI option and then ignored, so session/new keeps [models].default (grok-4.6) and oMLX never loads. Qwen/Hermes/Codex/Claude already bind the host in env or settings; Grok's only bind is this -m slug. * Pin Grok's local slug with session/set_model after session/new. -m after agent is not enough: session/new still starts on [models].default, so initialize reports grok-4.6 even when oMLX was picked. Set the slug over the wire (same as Hermes/Droid) and report that model on session.started. --------- Co-authored-by: Max <ajsdaksfjhs@gmail.com>
…ontrol Add Ubuntu screen preview and guarded local control betas
Bundle a pinned Cua Driver for Ubuntu x64
…#252) Midnight (the current palette, unchanged) plus three alternatives, switchable in Settings → General and remembered per machine. They fill a grid: dark/cool is Midnight, light/warm Atelier, dark/warm Foundry, light/cool Lagoon. This is mostly a CSS change because of a property this codebase already has: 916 token classes against 8 hardcoded hex values and no Tailwind greys. A skin is therefore one block of custom properties — Lagoon cost 51 lines. The selector is `[data-skin=…]` rather than `:root[data-skin=…]`, so any element can open a skin context for its subtree, which is how the picker renders a live miniature of each skin instead of a painted mockup. One token is doing real work beyond theming: --color-accent-ink, the ink carried by a filled accent or danger surface. Components hardcode `text-white` on those fills at ~20 sites, which holds only while every accent is dark enough to carry white. That is the constraint behind a measurement worth having on its own: white on the current accent is 3.65:1 and on danger 3.10:1, both short of the 4.5:1 that AA asks of the 12-14px labels they carry. Midnight sets the token to #ffffff so its rendering is byte-identical, while Foundry can invert the relationship — brass fill, near-black lettering, 7.4:1 — which is what lets it read as lit metal rather than as a dark paint chip. Same story for the disabled state: `disabled:opacity-40` works on a dark ground and inverts on a light one, where Atelier's accent at 40% over white lands on #d9bfa8 and white lettering measures 1.75:1. Disabled buttons get their own colours instead of transparency. Contrast is measured, not asserted: check-skin-contrast.mjs parses the stylesheet, reads @theme and :root as the inherited base — so a token a skin does NOT set is still measured as it ships — and fails on an undefined token rather than skipping it. 27 pairs per skin. Midnight is advisory, since its job is to be the original, gaps included.
Release Ubuntu installables for v0.1.25
…llisions (#277)
A team manifest is untrusted input — it can arrive from the remote catalog,
a GitHub repo, or a file someone shared. Import already only ever created
records (fresh ids from createBot, privileged member keys stripped by the
zod parse), but that guarantee was implicit: three accidental layers, no
single place that owned it, and no test that would catch a regression.
Two changes make it structural:
1. importedMemberProfile() is now the one boundary where a parsed member
becomes bot fields. It is an allowlist built field by field — name,
title, description, color, mascotExpression — so every privilege-bearing
BotRecord field (autoApprove, alwaysAllow, chiefOfStaff,
approvePeerComms, composio, computer, cloudBackend, cwd) is absent by
construction, whatever the file claimed. A future manifest field cannot
reach a bot record without consciously widening this return type. The
route still forces composio: false after creation, because that is the
one privilege where absence means allowed.
2. Name collisions are numbered, never merged. Display names are identity
wherever bots address each other (@mention resolution, the Chief of
Staff roster, peer-approval prompts), so an imported member wearing an
existing bot's name could be mentioned or granted as if it were that
bot. A colliding name now arrives visibly numbered ("Mira" -> "Mira 2"),
case-insensitively, hidden bots included, capped at the 100-char member
limit — the same convention the name generator already uses.
Re-importing the same file is documented behavior: it creates a second,
freshly numbered set and never reaches back into the first (a user's edits
to imported bots are theirs). Replace-mode archival is unchanged — it is
driven solely by the mode parameter the user chose, touches only
hidden/chiefOfStaff on their own bots, and nothing in the file influences
which bots it archives.
Tests cover the whole threat model at both layers: parse-level stripping of
every smuggled privileged field, profile-level allowlist + dedup edge cases
(case-insensitive, batch-internal, max-length), and route-level — claimed
ids go nowhere, every privileged field lands at its safe default, the armed
existing bot is untouched field for field, the single-Chief invariant
survives a chiefOfStaff claim, a legacy v1 room block neither creates nor
touches rooms, and a re-import after user edits leaves the edit intact.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Ship a macOS Intel (x64) build alongside Apple silicon
Closes #257. The published mac app was arm64-only; an Intel Mac cannot
run it and Rosetta does not translate in that direction.
- electron-builder.yml: dmg + zip for [arm64, x64]. The dmg section's
own artifactName override was arch-less, so the x64 dmg silently
OVERWROTE the arm64 one on the first dual-arch build and the feed
listed one filename twice with two different hashes — it now carries
${arch}. extraResources resolve through dist-native/${arch} so each
bundle ships its own natives.
- pnpm.supportedArchitectures.cpu = [arm64, x64]: pnpm installs both
darwin CUA native packages (x64 exists at the exact pinned 0.20.0);
lockfile is byte-identical.
- prepare-cua.mjs: stages per-arch dirs, asserts the driver binary is
genuinely universal via lipo (a future non-universal pin fails at
package time, not on a user's Intel Mac), and falls back to the
official universal download when the locally installed CuaDriver.app
is single-arch. The pure-JS SDK bundle is built once and shipped in
both dirs.
- build-speech-helper.mjs: two swiftc passes targeting
{arm64,x86_64}-apple-macos12 (the app's LSMinimumSystemVersion),
lipo'd and asserted universal — bare swiftc built host-arch only,
which inside an Intel app is a dictation helper that cannot launch.
- scripts/regenerate-mac-feed.mjs (new): refreshes latest-mac.yml
hashes after notarization stapling rewrites artifact bytes,
preserving electron-builder's file order (electron-updater filters
that list by arch: arm64-marked files on Apple silicon, unmarked on
Intel) and verifying every entry against the bytes on disk before
finishing.
- smoke-cua.mjs follows the per-arch staging layout; README gains the
Intel download row (stable name: OpenMausBot-intel.dmg).
Verified: four distinct artifacts; app binaries x86_64/arm64
respectively; universal cua-driver and speech helper inside the x64
bundle; the x64 app launched under Rosetta on this arm64 Mac with a
sandboxed data dir — server forked, /api/health static:true, all 8
spawned proxy paths resolving inside Resources/server; full suite
green (1187 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* README: give the Intel build its own download button
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ci: fail the run when suspiciously few tests were counted A green `vitest run` only proves that everything that ran passed — it says nothing about whether everything that should have run, ran. A test file that never registers its tests (a rename that slips out of the include globs, a directory dropped from the config, an import-time throw swallowed as an empty collection) leaves the surviving files passing and CI green with a chunk of the suite silently missing. scripts/test-floor.mjs closes that gap: it runs the same suite with a JSON reporter alongside the console one, counts the tests the run registered, and fails below TEST_COUNT_FLOOR (970 — roughly 90% of today's 1081 tests, a floor rather than an exact count so ordinary churn never touches it). It also refuses to report a pass when the count cannot be determined at all, because an uncountable run has exactly the shape of a collection failure. Registered tests are counted rather than executed ones so the POSIX-only tests that self-skip on Windows do not force a per-platform floor. The `test` script's leading `vitest run` becomes `node scripts/test-floor.mjs`; ci.yml is untouched and the broker/updater/packaged-server legs run as before. The pass/fail decision is a pure function with its own unit tests, which run inside the counted suite via a new scripts/**/*.test.mjs include glob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: preserve targeted test runs --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Log every authorization decision to a fleet-wide audit file The per-thread event log records that a permission request opened and later resolved, but not WHY: whether a grant waved it through, which guard held it, or that an unattended block withheld a grant that would otherwise have fired. That reason exists for one moment at the request.opened fold and was thrown away — so "which tool call was allowed, denied, or carded, by which rule, for which bot" had no answer. Now it does: ~/.openmausbot/decisions.ndjson, one row per decision, written where the decision is made. Auto-approvals are logged only after the provider takes the answer (same discipline as the transcript chip, so the log never claims an approval nothing received); every card that reaches a human logs which rule sent it there — including the most audit-worthy row of all, `unattended-block`, a grant withheld because nobody started the turn; and a human's allow/deny logs a second row when it actually reaches the engine. Rule attribution comes from a new autoVerdict() in auto-approve.ts that returns the matched guard regex or granted key alongside the unchanged verdict, so the call site never re-derives (and eventually mis-derives) the match. autoDecision() is now a thin wrapper over it; no decision changes in this commit. Same hygiene as the event tee: 0600, through redactSecrets, and fire-and-forget — an audit log must never take down the decision it is auditing. Rotation is a single .1 sidecar at 4MB, bounded rather than archival. GET /api/decisions?limit=N reads the newest rows back, newest last, matching the thread-events inspector. ask_bot peer-approval cards never cross the runtime bus and stay deliberately out of scope. Each wiring point was mutation-checked: dropping the card row, blanking the rule, skipping the user row, and mislabeling the unattended block each fail exactly the test written for them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make decision auditing durable --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ion per driver (#279) * Keep workspace credentials out of plaintext config and unrelated child env Two halves of one rule — a credential should be readable only by the process that needs it, and rest encrypted where the platform allows. At rest: the packaged app already moved the Composio key out of config.json into the OS-encrypted credentials.bin. The xai key, box token, voice key and OpenCode Go key now get the same sweep on boot (electron/workspace-credentials.mjs): plaintext values migrate into the encrypted store and the field is DELETED, not blanked — the server saves a mid-session change back into config.json, so a non-empty field is the newest user intent (overwrites the store), "" is a clear (drops the stored secret), and an absent field means already-migrated. The server now prefers env over the file for every credential (the shell injects one var per stored secret at spawn), with the file as the dev-mode fallback; syncCredentialEnv keeps the running process's env in step with a save so the boot-time value cannot shadow it until relaunch. In children: instanceConfigs() used to copy XAI_API_KEY and BOX_TOKEN into EVERY instance's environment; each key now goes only to the driver that reads it (grok API, boxAgent), the pattern opencodeGo already followed. And because the packaged server process now carries these secrets in its own env, every engine-CLI spawn point (claude, codex, antigravity, ACP core) strips WORKSPACE_CREDENTIAL_ENV from the child env — an engine that brings its own login has no business inheriting the box token or the voice key. Migration is idempotent and lossless; a downgrade behaves like the composio path (key re-entry, nothing corrupted). Every new guard was mutation-checked: broken deliberately, watched the new test fail, restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: complete encrypted credential handling --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…k for your hands (#280)
* computer: let a person take the wheel, and let the bot ask for hands
A bot mid-task on its computer had no way to hand the keyboard to the
person, and the person had no way to take it: opening the desktop viewer
while the bot kept clicking meant two sets of hands on one mouse.
The harness now keeps a per-bot who-is-driving record. The person takes
and releases control from the computer panel; while they hold it, every
computer action the bot attempts is refused — not queued, because a
queued click lands after the person has moved on. The bot's only verb is
computer_request_help: it can plead (with a notification and a panel
banner) and wait for the hand-back, but it can never take control or
clear a hold.
Enforcement lives where the actions flow: the box REST adapter refuses
in its own tools, and the Local VM / VPS stdio bridges gain an opt-in
line-level gate that answers tools/call with a refusal — the far side is
Cua Driver's own MCP server, which has no concept of a person holding
the wheel, so the refusal has to come from the near side. With no gate
configured the bridges stay byte-for-byte transparent. The gate fails
open on control-endpoint errors: pausing is cooperation between the
person and their own bot, not a security boundary, and a harness hiccup
must not brick every computer mid-turn.
Host CUA ("This Mac") is deliberately not gated — the person is at that
keyboard already, and Stop remains the way to halt a turn there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: harden human computer takeover
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Actions -> Release -> Run workflow now builds macOS (arm64 + x64, signed, notarized, stapled), Windows, and Ubuntu from ONE pinned commit, verifies every artifact the way a user receives it, assembles a complete draft on openmausbot-releases, and publishes when asked to. Every gate encodes an incident from the hand-cut releases: clean output dirs (0.1.15 broken seal), codesign before notarization (notarization accepts invalid signatures), the packaged-server smoke (0.1.24 died on launch on an unbundled import), the proxy-path probe (its fix broke every helper while health stayed green), staple-then-hash (stapling invalidates published feeds), blockmap regeneration, and assemble-then-publish (0.1.24 sat invisible as a draft). Needs four one-time secrets (Developer ID p12, App Store Connect API key, releases-repo PAT) — documented in docs/releasing.md along with the local fallback flow. smoke-packaged-server.mjs learns OMB_SMOKE_DIST so the workflow can aim it at each built app's Resources/server. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iPad has never had a layout pass; ship family 1 and revisit for 1.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: design configurable room turn timeout * feat: persist room turn timeout setting * test: cover room timeout config without provider reload * fix: honor configurable room turn timeout * feat: expose room timeout config to the app * feat: add room timeout setting to general settings * fix: serialize room timeout saves * fix: release stalled room turns * test: exercise room timeout update in a room
…ript (#269) * Add one pinned message per thread, shown as a banner above the transcript Closes #268. Threads accumulate exactly the content you keep scrolling back to — the constraint, the decision, the file path, the answer. A pin button joins the message hover actions; the one pinned message renders as a compact banner (sender, one line, click to jump, X to unpin) between the header and the transcript, in 1:1 threads and rooms. pinnedMessageId on the bot and group records, through the existing PATCH paths; stored verbatim and resolved against the transcript at render, so a pin whose message was edited to another branch or deleted simply renders nothing. Clearing sends "" (JSON.stringify drops undefined), which the server normalizes to an unset field. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover room pinning and empty-string clearing on PATCH /api/groups. The UI clears a pin with "", and rooms used the same field without an HTTP assertion. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
feat(ios): Messages-shaped companion — glass chrome, Updates bar, the desktop's mascot, tailed bubbles
* Adopt Apache License 2.0 * Complete Apache-only relicensing
Closes #263. The reaction bar offered five hardcoded emoji even though the store and API accept any emoji string — every other reaction was impossible from the UI. The quick strip stays exactly as it was; a "+" at its end opens an anchored popover with a 22-emoji palette in a 6x4 grid, following the same dismiss contract as the sidebar menus (outside click, Escape, window blur). Selecting toggles via the existing toggleReaction dispatch — no store or server changes. Co-authored-by: Cursor <cursoragent@cursor.com>
…, 6.9" screenshots - Bundle ids become com.openmausbot.app / .app.widgets: the .companion id is registered to another team and the portal refuses it; .app is the desktop's identity and registered cleanly under this team. Existing sideloads keep working; a store install pairs afresh. - ExportOptions.plist: app-store-connect upload, automatic signing. - Store screenshots at 1320×2868 (6.9"), dark and light, from the Simulator harness. Build 1.0.0 (1) uploaded from this configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chore(ios): App Store packaging — com.openmausbot.app, export options, 6.9" screenshots
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Wrap `agent acp` as a built-in subscription engine so bots can run on a Cursor login (or CURSOR_API_KEY) instead of only Claude/Codex/Grok. Co-authored-by: Nocturnus <NocturnusCoder@users.noreply.github.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7349458911
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| env: Record<string, string | undefined>, | ||
| run: typeof execCli = execCli, | ||
| ): Promise<ModelCatalog> { | ||
| const stdout = await execText(run, cli, ["models", "--format", "json"], env); |
There was a problem hiding this comment.
Retry Cursor model discovery without unsupported format flag
When the installed Cursor CLI follows the documented parameter set, agent models --format json can exit on the unsupported --format option: Cursor documents agent models/--list-models for model listing, while command-specific --format is listed only for status, whoami, and about (https://cursor.com/docs/cli/reference/parameters). Since execText returns null on any error and this code never retries plain agent models or --list-models, the picker silently stays on the static fallback instead of the account's live model catalog.
Useful? React with 👍 / 👎.
Summary
Adds a Cursor Agent CLI driver so OpenMausBot can run bots on a Cursor subscription via
agent acp(ACP stdio), matching the pattern used for Grok/Kimi/Droid.Changes
server/drivers/acp/cursor.ts): spawnsagent [--force] [--model …] acp; auth viaCURSOR_API_KEY/CURSOR_AUTH_TOKENoragent status/whoami; model catalog from plainagent modelstext (slug - Labelwith(default)/(current)markers)cursorinstance in product configsdocs/cursor.mdModel discovery
The live CLI does not support
agent models --format json. The driver callsagent modelsand parses lines like:Static fallback uses
auto/composer-2.5when listing fails.Testing
pnpm exec vitest run server/drivers/acp/cursor.test.ts— 15 passedFollow-up
Live smoke on a machine with
agent login:agent models, one OpenMausBot turn with Cursor engine.