Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Publish site/ to GitHub Pages. Needs Pages enabled with "Source: GitHub Actions" once, by hand.
name: Website

on:
push:
branches: [master]
paths: ["site/**", ".github/workflows/pages.yml"]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
# The banner lives in assets/ and is not duplicated into site/.
- run: cp assets/banner.png site/banner.png
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deploy
uses: actions/deploy-pages@v4
2 changes: 1 addition & 1 deletion apps/mac/Sources/XBotApp/XBotApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ struct XBotApp: App {
}
}

Window(String(localized: "Plugins"), id: "plugins-admin") {
Window(String(localized: "Engine Admin"), id: "plugins-admin") {
PluginsAdminView()
.environment(state)
}
Expand Down
6 changes: 6 additions & 0 deletions apps/mac/Sources/XBotRuntime/RuntimeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,12 @@ public actor RuntimeController {
for volume in [Self.dataVolume, Self.workspaceVolume, Self.profilesVolume] {
try? await driver.removeVolume(volume)
}
// The pre-upgrade dump is a plain-SQL copy of the database, kept outside the volume so a
// rollback can use it — which is exactly why removing the volumes never removed it.
try? FileManager.default.removeItem(at: dumpURL)
try? FileManager.default.removeItem(
at: dumpURL.deletingLastPathComponent().appendingPathComponent("restore.log")
)
// Back to stopped, not notDetected: the runtime is still installed and still working — it
// is only xBot's own data that is gone.
state = .stopped
Expand Down
12 changes: 9 additions & 3 deletions apps/mac/Sources/XBotUI/Admin/PluginsAdminView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import SwiftUI
import XBotCore
import XBotEngine

/// The upstream plugins admin UI, embedded per ADR-0004.
/// The upstream admin UI, embedded per ADR-0004. One window for every admin surface: the deep link
/// decides which, so this opens at plugins or at the admin index.
public struct PluginsAdminView: View {
@Environment(AppState.self) private var state

Expand All @@ -26,9 +27,14 @@ public struct PluginsAdminView: View {
Image(systemName: "puzzlepiece.extension")
.font(.system(size: 36, weight: .light))
.foregroundStyle(Palette.textSecondary)
Text(String(localized: "Plugins need a running engine"))
Text(String(localized: "These tools need a running engine"))
.sectionTitle()
Text(String(localized: "Start the engine from onboarding or the main window, then open Plugins again."))
Text(
String(
localized:
"The engine's admin tools are served by the engine itself. Start it from the main window, then open this again."
)
)
.bodyText()
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
Expand Down
8 changes: 7 additions & 1 deletion apps/mac/Sources/XBotUI/Settings/SettingsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,19 @@ public struct AdvancedSettingsView: View {
state.preparePluginsAdmin()
openWindow(id: "plugins-admin")
}
// One webview for every admin surface: upstream's admin has its own sidebar, so its index
// reaches credentials, computers, the playground and the rest without wiring each.
Button(String(localized: "Engine admin…")) {
state.preparePluginsAdmin(path: "admin")
openWindow(id: "plugins-admin")
}
} header: {
Text(String(localized: "Admin"))
} footer: {
Text(
String(
localized:
"Connect third-party services and choose which agents may use their tools. Opens the engine's plugins manager."
"Plugins connects third-party services and chooses which agents may use their tools. Engine admin opens the engine's own tools: credentials, computers, the playground, and people."
)
)
}
Expand Down
25 changes: 25 additions & 0 deletions apps/mac/Tests/XBotRuntimeTests/RuntimeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,31 @@ extension RuntimeControllerTests {
#expect(await controller.state == .stopped)
}

/// The pre-upgrade dump is the database in plain SQL — agents, settings, everything the volume
/// held. It lives outside the volume on purpose, so removing the volumes never touched it, and
/// the screen said "Everything xBot stored has been removed" while a copy sat in Application
/// Support. The restore log beside it goes too.
@Test func uninstallRemovesThePreUpgradeDump() async throws {
let dumpURL = isolatedDumpURL()
let directory = dumpURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Data("-- dump".utf8).write(to: dumpURL)
let logURL = directory.appendingPathComponent("restore.log")
try Data("log".utf8).write(to: logURL)
let controller = RuntimeController(
driver: FakeDriver(),
image: ImageReference(repository: "xbot/engine", tag: "1"),
health: { _ in EngineHealth(engineVersion: "0.0.5", schemaVersion: "0000") },
ports: isolatedPortStore(),
dumpURL: dumpURL
)

await controller.uninstall()

#expect(!FileManager.default.fileExists(atPath: dumpURL.path))
#expect(!FileManager.default.fileExists(atPath: logURL.path))
}

/// Uninstall runs once and cannot ask the person to try again, so a step whose work is already
/// done must not stop the rest. Without this, a half-uninstalled machine keeps its volumes.
@Test func uninstallSurvivesAnythingAlreadyGone() async {
Expand Down
2 changes: 1 addition & 1 deletion docs/03-openbot-fork.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ security-sensitive codebase within a year.
| Local-token auth | 3–4 days | **Done** — `EngineTokenStore`, bearer on loopback |
| Config surface → app settings | 1–2 weeks | **Done** — `EngineEnvironment` + [`env-mapping.md`](env-mapping.md) |
| Port negotiation + runtime driver | 1–2 weeks | **Done** — `DockerDriver`, adoption, `RuntimeController` |
| Admin webview embedding | 3–4 days | **Partially done** — plugins admin webview ships; other admin surfaces still open |
| Admin webview embedding | 3–4 days | **Done** — one webview; Plugins opens at `/admin/plugins`, Engine admin at `/admin`, whose sidebar reaches the rest |
| **Engine total** | **~9–14 weeks** | |

The Mac client is estimated separately in [12-roadmap.md](12-roadmap.md). The two streams can run in
Expand Down
6 changes: 3 additions & 3 deletions docs/09-ui-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The app drives a `RuntimeController` and `HTTPEngineClient` in production, and s
| Design system | Built — tokens, aurora field, frosted glass, Reduce Motion and Reduce Transparency inside tokens |
| Onboarding | **Built (M6 in progress)** — five steps, Colima install-for-me, engine adoption, provider keys, handoff to main window. VM clean-machine validation still open |
| Settings | **In the main window, not a separate scene** — the gear at the foot of the rail, ⌘, and Escape. General, Models, **Agents** (defaults), **Computer** (policy presets + boundaries admin), **Usage** (placeholder), Updates, Advanced |
| Plugins & admin | **Partial** — native grant toggles in agent settings; Plugins admin window (`WKWebView` at `/admin/plugins` with bearer injection). **The audit trail is native** (Settings → Audit), which is ADR-0004's one exception to the webview rule. Credentials, playground and the rest **not embedded** |
| Plugins & admin | **Partial** — native grant toggles in agent settings; Plugins admin window (`WKWebView` at `/admin/plugins` with bearer injection). **The audit trail is native** (Settings → Audit), which is ADR-0004's one exception to the webview rule. Credentials, playground and the rest reached from **Engine admin…** in the same window |

## The main window

Expand Down Expand Up @@ -362,7 +362,7 @@ A separate floating panel was tried first and removed: it put settings somewhere
and find, and it hid the rail — so which agent was selected stopped being visible while its model
was being changed.

**Shipped today:** General, Models, **Agents** (default model + description; shared preamble deferred), **Computer** (auto-review + preset deny rules + boundaries admin webview), **Usage** (honest placeholder — engine accounting still open), Updates (Sparkle scaffold + engine install with health rollback), Advanced (Plugins, uninstall).
**Shipped today:** General, Models, **Agents** (default model + description; shared preamble deferred), **Computer** (auto-review + preset deny rules + boundaries admin webview), **Usage** (real per-agent token counts; the agent sums `usage_metadata` across a turn and emits `xbot.usage`), Updates (Sparkle scaffold + engine install with health rollback), Advanced (Plugins, Engine admin, uninstall).

| Tab | Contents |
| --- | --- |
Expand Down Expand Up @@ -423,7 +423,7 @@ from Settings → Advanced, not inside the main window.
| --- | --- |
| **Plugins (full manager)** | `WKWebView` at the engine's `/admin/plugins` — OAuth setup, catalogue, per-tool config |
| **Plugin grants (per agent)** | Native — **What it can reach** and **Handoff grants** in the panel's Agent settings |
| Everything else in the table above | **Not embedded yet** — same webview pattern when added |
| **Everything else** — credentials, boundaries, computers, skills, components, playground, people, identity providers | Same webview, opened at `/admin` from Settings → Advanced → **Engine admin…**. Upstream's admin sidebar reaches each one, so no per-surface wiring |

The webview injects the loopback bearer token at document start so the upstream React admin can call
`/api` without a sign-in flow. The token never appears in page-visible UI.
Expand Down
8 changes: 6 additions & 2 deletions docs/11-packaging-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,17 @@ is a bad citizen with a reputation problem.
logins"
4. Remove Keychain items
5. Remove `UserDefaults`
6. Offer to move the app to the Trash
6. Remove the pre-upgrade database dump in Application Support — a plain-SQL copy of the database,
kept outside the volume so rollback can use it, and therefore untouched by step 3
7. Tell the person they can now drag the app to the Trash. **Not done for them**: moving a running
app's own bundle is not something an app should try, so the screen says it instead

**Does not remove the container runtime**, because the user may have installed it for something else.
It says so.

**Also ship a standalone uninstaller script** in the DMG for the user who already dragged the app to
the Trash and then found the volumes. ⚠️ Yes, this is a terminal — it is the one place the no-terminal
the Trash and then found the volumes. It is `Uninstall xBot.command`, copied from
`scripts/uninstall-xbot.command`, and it mirrors `AppState.uninstall()` step for step. ⚠️ Yes, this is a terminal — it is the one place the no-terminal
promise yields, because the alternative is orphaned data with no way to remove it. It is documented
on the website, not in the app.

Expand Down
12 changes: 8 additions & 4 deletions docs/12-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ digest from `manifests/engine-stable.json`.
it works against a live engine and not only the stub. It used to reach the message bubble and
nowhere else, and `activity(for:)` returns an empty list on HTTP by design, so the panel promised
"commands, files, and pages will show up here" and nothing could deliver it.
- Plugins admin webview + native grant toggles (partial — other admin surfaces still open).
- Plugins admin webview + native grant toggles. Other admin surfaces via **Engine admin…** (same webview).

**Done when:** create an agent in the app, send a message, watch it browse, take control, hand it
back — all native. `scripts/verify-m5-handoff.sh` covers the smoke path against a running engine.
Expand Down Expand Up @@ -258,14 +258,18 @@ it.**
still open.**
- Engine update flow including rollback, and the migration-rollback decision. **Done** — the dump,
per docs/11's recommendation: taken before the new image can migrate, restored on rollback.
- Uninstall, complete.
- Uninstall, complete. **Done** — Settings → Advanced removes the container, volumes, Keychain items,
preferences and the pre-upgrade dump (which it used to leave behind); `Uninstall xBot.command` ships
in the DMG for somebody who already trashed the app.
- Admin surfaces embedded (webview). **Plugins admin ships; the audit trail is native rather than
embedded, per ADR-0004's exception; credentials, playground, etc. still open.**
embedded, per ADR-0004's exception; every other surface opens in the same window at `/admin`, whose own sidebar reaches credentials,
computers, playground and the rest.**
- Settings: General, Models, Agents, Computer, Advanced, Updates. **Built** — all seven panes, in the
main window rather than a separate scene.
- The honest v1 limitations stated in the UI: shared browser, shared workspace. **Done** — Settings →
Computer, in the wording docs/10-security.md specifies.
- Website with the download and the security explanation.
- Website with the download and the security explanation. **Built** — `site/index.html`, published by
`.github/workflows/pages.yml`; enabling Pages is a one-time repository setting.

**The first-run supply chain is verified anonymously**, which is the part of "installs from the
website and uses it" that does not need a person. From a shell holding no credentials: the manifest
Expand Down
27 changes: 18 additions & 9 deletions docs/13-launch-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ wrong identity string and is a missing certificate.
Updates are dead until this exists, and it cannot be retrofitted: an app shipped without the public
key baked in can never verify an update, so v1.0 users would be stranded on v1.0 forever.

1. Generate the EdDSA key pair with Sparkle's tool (`generate_keys` from the Sparkle release
archive). It writes the private key to your login Keychain and prints the public key.
2. Add two more secrets: `SPARKLE_EDDSA_PRIVATE_KEY` (the private key) and `XBOT_SPARKLE_PUBLIC_KEY`
(the public one, which gets baked into the bundle).
1. Run `scripts/generate-sparkle-keys.sh`. It writes the private key to your login Keychain and
prints the public key. There is nothing to download — Sparkle's own `generate_keys` is already in
the SwiftPM artifacts, and the script only finds it. Running it twice is safe: it prints the
existing public key rather than replacing a key your shipped builds were signed against.
2. Add two more secrets: `SPARKLE_EDDSA_PRIVATE_KEY` and `XBOT_SPARKLE_PUBLIC_KEY` (the public one,
which gets baked into the bundle). The private key is in the Keychain, not on disk, so export it
first — `apps/mac/.build/artifacts/sparkle/Sparkle/bin/generate_keys -x sparkle-private.key` —
paste the file's contents into the secret, and then delete the file. That export is the key
itself: anything holding it can sign an update your users' apps will install.
3. Decide where the appcast lives and set `XBOT_APPCAST_URL` to it. GitHub Releases plus a raw file
in the repo is enough to start; `XBOT_RELEASE_DOWNLOAD_PREFIX` is optional and only needed if the
DMG is served from somewhere other than the appcast's own host.
Expand Down Expand Up @@ -163,12 +168,16 @@ bugs, and they are only visible on the first run.

---

## 7. The website
## 7. The website — **built; needs Pages turned on**

The last item in M7 and the only one with no code in this repository. It needs the download, the
security explanation, and — per ADR-0007 — the fact that conversation history is stored by
CopilotKit, said plainly rather than buried. The README already carries that wording; reuse it
rather than writing a second version that can drift.
`site/index.html` is the page: one file, no build step. It carries the download (pointing at
`releases/latest`), the security explanation, the uninstall instructions — per docs/11 the standalone
uninstaller is documented here and not in the app — and, per ADR-0007, the fact that conversation
history is stored by CopilotKit, in the README's wording rather than a second version that can drift.

`.github/workflows/pages.yml` publishes it on every push to master that touches `site/`. **It needs
you once:** repository → Settings → Pages → Source: **GitHub Actions**. Until then the workflow fails
at the deploy step. The download link is only useful once step 4 has published a release.

---

Expand Down
2 changes: 2 additions & 0 deletions scripts/create-dmg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ mkdir -p "${STAGE}"

cp -R "${APP}" "${STAGE}/"
ln -s /Applications "${STAGE}/Applications"
cp "${ROOT}/scripts/uninstall-xbot.command" "${STAGE}/Uninstall xBot.command"
chmod +x "${STAGE}/Uninstall xBot.command"

hdiutil create -volname "${VOLUME}" -srcfolder "${STAGE}" -ov -format UDZO "${DMG}"
rm -rf "${STAGE}"
Expand Down
28 changes: 28 additions & 0 deletions scripts/generate-sparkle-keys.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Print the Sparkle EdDSA public key, generating the pair on first run.
#
# docs/13-launch-checklist.md step 3. Sparkle's own tool is already in the SwiftPM artifacts, so
# there is nothing to download: this only finds it and runs it.
#
# The private key goes into your login Keychain, which is where Sparkle's tool puts it. Back it up —
# losing it strands every shipped version, because an app cannot verify an update signed by a key it
# has never seen.
set -euo pipefail

MAC="$(cd "$(dirname "$0")/../apps/mac" && pwd)"

GENERATE_KEYS="$(find "${MAC}/.build/artifacts/sparkle" -name generate_keys -type f 2>/dev/null | head -1)"
if [[ -z "${GENERATE_KEYS}" ]]; then
echo "Sparkle's generate_keys is not built yet — run: cd apps/mac && swift build -c release" >&2
exit 1
fi

# -p prints the public key for a pair that already exists, and says so if there is none, so a second
# run never quietly replaces the key every shipped build was signed against.
if "${GENERATE_KEYS}" -p 2>/dev/null; then
echo
echo "That is XBOT_SPARKLE_PUBLIC_KEY. The private key is already in your login Keychain."
exit 0
fi

"${GENERATE_KEYS}"
Loading
Loading