diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92aa592..9af50b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,18 @@ jobs: globs: | docs/**/*.md + no-private-identifiers: + name: No private identifiers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: ShellCheck + run: shellcheck scripts/check-no-private-identifiers.sh scripts/test-check-no-private-identifiers.sh + - name: Repo is clean, and the check fails on a forbidden pattern + run: | + bash scripts/check-no-private-identifiers.sh + bash scripts/test-check-no-private-identifiers.sh + pw-mcp-loopback: name: Playwright MCP stays on loopback (#115) runs-on: ubuntu-latest @@ -46,7 +58,11 @@ jobs: - name: Build run: swift build - name: Packaging smoke (unsigned; release signing is tag-only) - run: scripts/test-packaging.sh .build/debug/oab-instance-mcp + run: scripts/test-packaging.sh .build/debug/oab-computer + - name: Name consistency and warn-only old-agent check (stub launchctl/systemctl) + run: | + bash scripts/test-name-consistency.sh + bash scripts/test-legacy-agent.sh - name: Test run: swift test @@ -81,10 +97,14 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: ShellCheck - run: shellcheck scripts/install-linux.sh scripts/test-install-linux.sh + run: shellcheck scripts/install-linux.sh scripts/test-install-linux.sh scripts/test-name-consistency.sh scripts/test-legacy-agent.sh - name: Generated units (fake HOME, stubbed systemctl/tailscale/loginctl) # Diffs each written unit against its expected text and runs systemd-analyze verify (#90). run: bash scripts/test-install-linux.sh + - name: Names agree between installer, units and release assets; old agent only warned about + run: | + bash scripts/test-name-consistency.sh + bash scripts/test-legacy-agent.sh linux-hands-node: name: Linux hands node (${{ matrix.arch }}) @@ -148,8 +168,8 @@ jobs: run: bash smoke.sh - uses: actions/upload-artifact@v4 with: - name: reverse-attach-linux-${{ matrix.arch }} - path: poc/reverse-attach-linux/target/release/reverse-attach + name: oab-computer-linux-${{ matrix.arch }} + path: poc/reverse-attach-linux/target/release/oab-computer - name: Smoke a plain build (reports -dev) # After the upload: rebuilds only this crate without OAB_RELEASE_VERSION, so smoke.sh # checks the "-dev" fallback a local build reports (#74). The first smoke run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30569da..a3942f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ permissions: contents: write concurrency: - group: instance-mcp-release-${{ github.ref }} + group: oab-computer-release-${{ github.ref }} cancel-in-progress: false jobs: @@ -73,13 +73,13 @@ jobs: swift build -c release --arch arm64 --scratch-path "$RUNNER_TEMP/build-arm64" swift build -c release --arch x86_64 --scratch-path "$RUNNER_TEMP/build-x86_64" /usr/bin/lipo -create \ - "$RUNNER_TEMP/build-arm64/release/oab-instance-mcp" \ - "$RUNNER_TEMP/build-x86_64/release/oab-instance-mcp" \ - -output "$DIST/oab-instance-mcp" - chmod 755 "$DIST/oab-instance-mcp" - /usr/bin/lipo -info "$DIST/oab-instance-mcp" | grep -q 'x86_64 arm64\|arm64 x86_64' - [ "$($DIST/oab-instance-mcp --version)" = "$VERSION" ] - scripts/assemble-app.sh "$DIST/oab-instance-mcp" "$DIST/oab-instance-mcp.app" "$VERSION" + "$RUNNER_TEMP/build-arm64/release/oab-computer" \ + "$RUNNER_TEMP/build-x86_64/release/oab-computer" \ + -output "$DIST/oab-computer" + chmod 755 "$DIST/oab-computer" + /usr/bin/lipo -info "$DIST/oab-computer" | grep -q 'x86_64 arm64\|arm64 x86_64' + [ "$($DIST/oab-computer --version)" = "$VERSION" ] + scripts/assemble-app.sh "$DIST/oab-computer" "$DIST/oab-computer.app" "$VERSION" - name: Import Developer ID certificates shell: bash @@ -110,7 +110,7 @@ jobs: shell: bash run: | set -euo pipefail - APP="$DIST/oab-instance-mcp.app" + APP="$DIST/oab-computer.app" /usr/bin/codesign --force --deep --options runtime --timestamp \ --keychain "$KEYCHAIN" --sign "$APP_SIGN_IDENTITY" "$APP" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP" @@ -122,18 +122,18 @@ jobs: --key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait xcrun stapler staple "$APP" xcrun stapler validate "$APP" - rm -f "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" + rm -f "$DIST/oab-computer-$VERSION-universal.app.zip" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP" \ - "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" + "$DIST/oab-computer-$VERSION-universal.app.zip" - name: Build, sign, and notarize installer pkg shell: bash run: | set -euo pipefail - PKG="$DIST/oab-instance-mcp-$VERSION-universal.pkg" + PKG="$DIST/oab-computer-$VERSION-universal.pkg" EXPECT_TEAM="$EXPECTED_TEAM_ID" PKG_SIGN_IDENTITY="$INSTALLER_SIGN_IDENTITY" \ PKG_KEYCHAIN="$KEYCHAIN" \ - scripts/package-pkg.sh "$DIST/oab-instance-mcp.app" "$PKG" "$VERSION" + scripts/package-pkg.sh "$DIST/oab-computer.app" "$PKG" "$VERSION" /usr/sbin/pkgutil --check-signature "$PKG" | grep -q 'Developer ID Installer' xcrun notarytool submit "$PKG" \ --key "$RUNNER_TEMP/AuthKey.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait @@ -141,7 +141,7 @@ jobs: xcrun stapler validate "$PKG" /usr/sbin/spctl -a -vv --type install "$PKG" scripts/verify-release.sh \ - "$DIST/oab-instance-mcp-$VERSION-universal.app.zip" "$PKG" "$EXPECTED_TEAM_ID" + "$DIST/oab-computer-$VERSION-universal.app.zip" "$PKG" "$EXPECTED_TEAM_ID" - name: Checksums and release shell: bash @@ -149,14 +149,14 @@ jobs: set -euo pipefail cd "$DIST" /usr/bin/shasum -a 256 \ - "oab-instance-mcp-$VERSION-universal.app.zip" \ - "oab-instance-mcp-$VERSION-universal.pkg" > SHA256SUMS + "oab-computer-$VERSION-universal.app.zip" \ + "oab-computer-$VERSION-universal.pkg" > SHA256SUMS gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ --verify-tag --generate-notes --title "oab-computer $VERSION" gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --clobber \ - "oab-instance-mcp-$VERSION-universal.app.zip" \ - "oab-instance-mcp-$VERSION-universal.pkg" SHA256SUMS + "oab-computer-$VERSION-universal.app.zip" \ + "oab-computer-$VERSION-universal.pkg" SHA256SUMS - name: Clean signing material if: always() @@ -219,9 +219,9 @@ jobs: shell: bash run: | set -euo pipefail - NAME="oab-instance-mcp-$VERSION-linux-${{ matrix.arch }}" + NAME="oab-computer-$VERSION-linux-${{ matrix.arch }}" mkdir -p "$NAME" - cp poc/reverse-attach-linux/target/release/reverse-attach "$NAME/oab-instance-mcp" + cp poc/reverse-attach-linux/target/release/oab-computer "$NAME/oab-computer" cp poc/reverse-attach-linux/pw-mcp.sh scripts/install-linux.sh "$NAME/" cp poc/reverse-attach-linux/README.md "$NAME/README.md" tar czf "$NAME.tar.gz" "$NAME" diff --git a/Package.swift b/Package.swift index fa76871..624025b 100644 --- a/Package.swift +++ b/Package.swift @@ -2,10 +2,10 @@ import PackageDescription let package = Package( - name: "oab-instance-mcp", + name: "oab-computer", platforms: [.macOS(.v14)], products: [ - .executable(name: "oab-instance-mcp", targets: ["oab-instance-mcp"]), + .executable(name: "oab-computer", targets: ["oab-computer"]), .library(name: "InstanceMCPCore", targets: ["InstanceMCPCore"]), ], targets: [ @@ -23,15 +23,15 @@ let package = Package( ), // Thin CLI: flag parsing + wiring. No logic worth testing lives here. .executableTarget( - name: "oab-instance-mcp", + name: "oab-computer", dependencies: ["InstanceMCPCore"], - path: "Sources/oab-instance-mcp" + path: "Sources/oab-computer" ), .testTarget( name: "InstanceMCPCoreTests", // The executable is a dependency only so it is built beside the tests: one test runs it with the hidden // permission-check argument. - dependencies: ["InstanceMCPCore", "oab-instance-mcp"], + dependencies: ["InstanceMCPCore", "oab-computer"], path: "Tests/InstanceMCPCoreTests" ), ] diff --git a/README.md b/README.md index 7f958db..82220da 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# OpenAB Computer (`oab-instance-mcp`) +# OpenAB Computer (`oab-computer`) -> **Name.** The product is now OpenAB Computer (repository `openabdev/openab-computer`, tracked in -> [#63](https://github.com/openabdev/openab-computer/issues/63)). The executable, bundle id, release -> asset names, config directory and Linux unit names keep the old `instance-mcp` names on purpose: -> macOS TCC grants are keyed to the bundle id `dev.openab.instance-mcp`, and changing it would drop -> every grant. Commands in this README use those names. +> **Name.** The product is OpenAB Computer (repository `openabdev/openab-computer`, tracked in +> [#63](https://github.com/openabdev/openab-computer/issues/63)). Since 0.12.0 the executable, bundle +> id (`dev.openab.computer`), LaunchAgent, systemd units, directories and release assets are all named +> `oab-computer`. It was `oab-instance-mcp` up to 0.11; see +> [Upgrading from 0.11 or earlier](#upgrading-from-011-or-earlier). Commands in this README use the new names. The "hands and feet" half of [openabdev/openab#1544](https://github.com/openabdev/openab/issues/1544): a thin daemon that lives in a Mac's logged-in desktop session and exposes the machine to a @@ -33,7 +33,7 @@ flowchart LR s8444[":8444 → 127.0.0.1:8795
injects Tailscale-User-Login"] end subgraph gui["Aqua session · LaunchAgents in gui/501"] - mcp["oab-instance-mcp (Swift)
auth = allow-login AND bearer token
sys_info · exec · exec_start/poll/list/cancel
screenshot · ui_snapshot · ui_action · mouse · key · osascript"] + mcp["oab-computer (Swift)
auth = allow-login AND bearer token
sys_info · exec · exec_start/poll/list/cancel
screenshot · ui_snapshot · ui_action · mouse · key · osascript"] pw["@playwright/mcp
headed Chromium, persistent profile"] end dial["--switchboard client
dials out to /vm/attach"] @@ -43,7 +43,7 @@ flowchart LR input["Input
CGEvent mouse / keyboard"] apps["Apps
osascript / JXA"] end - tcc{{"TCC grants, once, on the Mac's own screen
Screen Recording · Accessibility · Full Disk Access
bound to bundle id dev.openab.instance-mcp"}} + tcc{{"TCC grants, once, on the Mac's own screen
Screen Recording · Accessibility · Full Disk Access
bound to bundle id dev.openab.computer"}} end cli -- "HTTPS · MCP Streamable HTTP" --> s8444 @@ -74,11 +74,11 @@ this exists for what SSH cannot reach. - [Goal](#goal) · [Features](#features) · [Status and known limits](#status-and-known-limits) · [Quick start](#quick-start) · [Platforms](#platforms) - [Tools](#tools) · [Auth](#auth) · [Network interfaces and ports](#network-interfaces-and-ports) - [Reverse attach](#lending-this-mac-to-a-sandboxed-agent-reverse-attach) · [Switchboard](#serving-a-switchboard---switchboard) · [Browser tools](#browser-tools-for-lent-sessions---upstream) -- Deploy & operate: [Download and install](#download-and-install) · [Build & test](#build--test-on-the-build-mac-the-laptop-never-compiles-swift) · [Deploy](#deploy-run-on-the-target) · [Menu bar](#menu-bar) · [Operate](#operate) +- Deploy & operate: [Download and install](#download-and-install) · [Upgrading from 0.11](#upgrading-from-011-or-earlier) · [Build & test](#build--test-on-the-build-mac-the-laptop-never-compiles-swift) · [Deploy](#deploy-run-on-the-target) · [Menu bar](#menu-bar) · [Operate](#operate) - Operator notes: [TCC & signing](#tcc-grants-survive-re-deploys-only-if-the-signature-does) · [Gotchas](#gotchas) - Detail: [UI tools](docs/ui-tools.md) · [Operator notes and gotchas](docs/operators.md) -Components: `oab-instance-mcp` is this package (Swift on macOS, zero deps; Rust on Linux). The +Components: `oab-computer` is this package (Swift on macOS, zero deps; Rust on Linux). The browser server is `@playwright/mcp`, not ours ([`poc/pw-mcp/README.md`](poc/pw-mcp/README.md)). Design notes: [`docs/requirements/connect-closed-loop.md`](docs/requirements/connect-closed-loop.md). @@ -124,7 +124,7 @@ The accessibility-tree tools are macOS only for now; Linux is deferred ## Quick start -**macOS** — download `oab-instance-mcp-VERSION-universal.pkg` from +**macOS** — download `oab-computer-VERSION-universal.pkg` from [Releases](https://github.com/openabdev/openab-computer/releases), double-click, and follow the permissions wizard ([details](#download-and-install)). Then point a caller at it: @@ -150,15 +150,15 @@ curl -s https://..ts.net/mcp/ \ ([full guide](docs/linux-setup.md)): ```sh -V=$(curl -fsSL https://api.github.com/repos/openabdev/openab-computer/releases/latest | python3 -c 'import sys,json;print(json.load(sys.stdin)["tag_name"][1:])') +V=0.12.0 # the first release with oab-computer-* assets (v0.11.0 and older use the old names) A=$(dpkg --print-architecture) # arm64 | amd64 -curl -fsSLO "https://github.com/openabdev/openab-computer/releases/download/v$V/oab-instance-mcp-$V-linux-$A.tar.gz" -tar xzf oab-instance-mcp-$V-linux-$A.tar.gz && cd oab-instance-mcp-$V-linux-$A +curl -fsSLO "https://github.com/openabdev/openab-computer/releases/download/v$V/oab-computer-$V-linux-$A.tar.gz" +tar xzf oab-computer-$V-linux-$A.tar.gz && cd oab-computer-$V-linux-$A ./install-linux.sh # a box with no desktop: add --headless-seat ``` The URL is `https://..ts.net:8444/mcp` and the bearer token is in -`~/.config/oab-instance-mcp/token` on the node. +`~/.config/oab-computer/token` on the node. ## Platforms @@ -176,7 +176,7 @@ The URL is `https://..ts.net:8444/mcp` and the bearer token is in The rest of this README is the macOS reference; the two platforms share the wire contract, auth and reverse-attach shape, so callers configure them identically. On Linux the job tools (`exec_start`, `exec_poll`, `exec_list`, `exec_cancel`) take the same arguments and return the -same `structuredContent`; their logs are under `$XDG_STATE_HOME/oab-instance-mcp/jobs/` +same `structuredContent`; their logs are under `$XDG_STATE_HOME/oab-computer/jobs/` (default `~/.local/state/…`), and they are `owner`-only there too ([`docs/linux-setup.md`](docs/linux-setup.md#background-jobs)). @@ -186,7 +186,7 @@ same `structuredContent`; their logs are under `$XDG_STATE_HOME/oab-instance-mcp |---|---|---| | `sys_info` | host, OS, chip, displays, tailnet IPs, console user, TCC status | call first; tells the model what will work | | `exec` | `zsh -f -c ` as the desktop user | `cwd` (a leading `~` is expanded and validated — a missing dir or unreadable external volume returns a clear error, not a hang), `env`, `timeout_secs` (≤600), `max_output_bytes` (≤1 MiB/stream). Spawned with `POSIX_SPAWN_SETSID`; timeout → `killpg` → exit 137, `timed_out=true`. `structuredContent` carries exit/stdout/stderr/duration. Use for commands that finish in seconds | -| `exec_start` | start a background job, return a `job_id` immediately | for work that outlives one request (release builds). Same shell/session/TCC as `exec`; `cwd`, `env`, `timeout_secs` (0 = no timeout, stop via `exec_cancel`). stdout/stderr are tee'd to `~/Library/Logs/oab-instance-mcp/jobs/.out`/`.err` (never truncated; survive after the job is forgotten; `tail -f`-able) | +| `exec_start` | start a background job, return a `job_id` immediately | for work that outlives one request (release builds). Same shell/session/TCC as `exec`; `cwd`, `env`, `timeout_secs` (0 = no timeout, stop via `exec_cancel`). stdout/stderr are tee'd to `~/Library/Logs/oab-computer/jobs/.out`/`.err` (never truncated; survive after the job is forgotten; `tail -f`-able) | | `exec_poll` | fetch a job's state + incremental output | `job_id`, optional `stdout_since`/`stderr_since` byte offsets (from the prior poll) for only-new output; reads from the log files by seek. Terminal `state` (`exited`/`killed`) carries `exit_code`; `out_path`/`err_path` point at the files | | `exec_list` | all running jobs + the 10 most recently finished | recover a forgotten `job_id` or see what is running; each entry has state, pid, exit_code, command, cwd, timestamps, stdout/stderr byte sizes | | `exec_cancel` | stop a running job (or drop a finished one) | `job_id`, `signal` `KILL` (default) / `TERM` — signals the whole process group; poll once more for final output. Finished jobs are dropped from the registry (log files stay on disk) | @@ -227,12 +227,12 @@ Configured checks are **AND**-combined: a request must pass every check that is - `--token ` / `--token-file

` — additionally require `Authorization: Bearer`, constant-time compared. The token after the `Bearer` scheme (case-insensitive) must match byte for byte; whitespace around the whole header value is stripped by the HTTP parser, nothing inside it is. - **`deploy.sh` now generates one at `~/.config/oab-computer/token` on macOS (mode 600; Linux keeps `~/.config/oab-instance-mcp/token`) and passes + **`deploy.sh` now generates one at `~/.config/oab-computer/token` (mode 600) and passes `--token-file`**, so the deployed agent requires *both* an allow-listed Tailscale login *and* the bearer token. This is defence-in-depth: a leaked tailnet auth key that enrols a node as your Tailscale login passes the login check but still gets `401` without the token (verified 2026-09-23: no token → 401, correct token → 200, wrong token → 401). The token is stable across - re-deploys; rotate by deleting `~/.config/oab-computer/token` (macOS) and re-deploying. The first 0.12 run copies a 0.11 token from `~/.config/oab-instance-mcp/token` once, when `~/.config/oab-computer` is created; after that the old file is never read again, so a deleted token is not restored from it. (The switchboard files and grants still live under `~/.config/oab-instance-mcp` for now.) The menu bar shows a masked form and + re-deploys; rotate by deleting `~/.config/oab-computer/token` (macOS) and re-deploying. The first 0.12 run copies a 0.11 token from `~/.config/oab-instance-mcp/token` once, when `~/.config/oab-computer` is created; after that the old file is never read again, so a deleted token is not restored from it. The menu bar shows a masked form and copies the full token (see below); it is never written to `agent.log`. - `--insecure-local` — allow bare loopback requests with no Tailscale headers. Debugging only. - `/healthz` is unauthenticated and says only `ok`. @@ -368,7 +368,7 @@ differences: - There is no switchboard and no menu bar. - `install-linux.sh` runs `sudo tailscale serve` for `:8444` only with passwordless sudo. Otherwise it prints the command. -- `install-linux.sh` also starts Playwright MCP on `127.0.0.1:8794` (user unit `oab-pw-mcp.service`) +- `install-linux.sh` also starts Playwright MCP on `127.0.0.1:8794` (user unit `oab-computer-pw-mcp.service`) when `node` and `npm` are present, and sets `MCP_UPSTREAM=browser=http://127.0.0.1:8794/mcp`. Skip it with `--no-browser`. It makes no tailnet mapping for 8794. `MCP_UPSTREAM` takes plain `http://` URLs only. @@ -445,9 +445,9 @@ runs. ```sh openab-sb gen-secret # on the switchboard: verifier → [vm].secret_sha256 -oab-instance-mcp --token-file ~/.config/oab-computer/token \ +oab-computer --token-file ~/.config/oab-computer/token \ --switchboard wss://..ts.net/vm/attach \ - --switchboard-secret-file ~/.config/oab-instance-mcp/switchboard.secret \ + --switchboard-secret-file ~/.config/oab-computer/switchboard.secret \ --switchboard-profile observe # observe (default) | owner ``` @@ -463,7 +463,7 @@ oab-instance-mcp --token-file ~/.config/oab-computer/token \ does not clean an existing log, so rotate audit files written by an older release. - **Secure fields:** `ui_action` `set_value` on a password field is refused through a switchboard by default, and nothing is typed into the app. To allow it, write `allow` to - `~/.config/oab-instance-mcp/switchboard.secure-set-value` and re-run the installer (flag + `~/.config/oab-computer/switchboard.secure-set-value` and re-run the installer (flag `--switchboard-allow-secure-set-value`). Do this only when the switchboard has `audit_args = false` or runs openab-sb 0.2.2 or later. Otherwise, fill secure fields over a direct `/mcp` connection. @@ -473,7 +473,7 @@ oab-instance-mcp --token-file ~/.config/oab-computer/token \ minutes on `401`/`403` instead of stopping. The contract is openab-sb `SOUTHBOUND-CONTRACT.md`. - **Rotation:** the secret file is re-read on every dial. Update the file, and the next retry picks it up. -- **Installed with the package:** put the URL in `~/.config/oab-instance-mcp/switchboard.url`, the +- **Installed with the package:** put the URL in `~/.config/oab-computer/switchboard.url`, the secret in `switchboard.secret` (mode 600) and, optionally, the profile in `switchboard.profile` and `allow` in `switchboard.secure-set-value`. Then re-run the installer: it writes the flags into the LaunchAgent plist whenever both files @@ -514,7 +514,7 @@ Playwright's tools under its own `tools/list`, filtered by the connection's prof Tagged releases publish a universal, Developer-ID-signed and Apple-notarized installer: -1. Download `oab-instance-mcp-VERSION-universal.pkg` from +1. Download `oab-computer-VERSION-universal.pkg` from [GitHub Releases](https://github.com/openabdev/openab-computer/releases). 2. Sign into Tailscale and keep a desktop user logged in. 3. Double-click the package. It auto-detects your Tailscale login/name, preserves or creates the @@ -525,7 +525,7 @@ Tagged releases publish a universal, Developer-ID-signed and Apple-notarized ins both are on. Click Allow on each row, turn the switch on in System Settings, and return to the wizard, which re-tests on its own. Use Check Again if the row does not update. Page 2 is optional Full Disk Access, which has a Skip link. Browser tools work without any of them. Future releases - keep the same Developer ID + bundle id, so grants survive. + keep the same Developer ID + bundle id, so grants survive (the 0.12 rename changed the bundle id once; see [Upgrading from 0.11 or earlier](#upgrading-from-011-or-earlier)). 5. The same wizard remains available from the menu bar as **Set Up Permissions…**; use the menu item to copy the MCP URL and bearer token into OpenAB Connect/Remote. @@ -534,20 +534,88 @@ Linux hands nodes: see [Quick start](#quick-start) and [`docs/linux-setup.md`](d The `.app.zip` beside the package is an advanced/manual artifact. After unzipping: ```sh -/path/to/oab-instance-mcp.app/Contents/Resources/install-prebuilt.sh \ - /path/to/oab-instance-mcp.app --allow-login auto +/path/to/oab-computer.app/Contents/Resources/install-prebuilt.sh \ + /path/to/oab-computer.app --allow-login auto ``` The installer never re-signs the app: doing so would change the identity TCC grants are bound to. See [`docs/releasing.md`](docs/releasing.md) for artifacts, signing/notarization, required secrets, local packaging smoke, and the current first-release signing blocker. +## Upgrading from 0.11 or earlier + +0.12.0 renames the product's technical names from `oab-instance-mcp` to `oab-computer`. This is a +breaking change with no compatibility layer: nothing is read from or written to the old names, except +the token copy in step 3 (the old path is its source only) and the warn-only check for the old agent. The product name stays OpenAB Computer. + +| | 0.11 and earlier | 0.12.0 | +|---|---|---| +| Executable, app file | `oab-instance-mcp`, `oab-instance-mcp.app` | `oab-computer`, `oab-computer.app` | +| Bundle id, LaunchAgent label | `dev.openab.instance-mcp` | `dev.openab.computer` | +| Playwright LaunchAgent, Linux unit | `dev.openab.instance-mcp.pw-mcp`, `oab-pw-mcp.service` | `dev.openab.computer.pw-mcp`, `oab-computer-pw-mcp.service` | +| Linux unit | `oab-instance-mcp.service` | `oab-computer.service` | +| Install, config, logs | `~/.local/oab-instance-mcp`, `~/.config/oab-instance-mcp`, `~/Library/Logs/oab-instance-mcp` | `~/.local/oab-computer`, `~/.config/oab-computer`, `~/Library/Logs/oab-computer` | +| Saved grants, audit log (macOS), state (Linux) | `~/Library/Application Support/oab-instance-mcp`, `$XDG_STATE_HOME/oab-instance-mcp` | `~/Library/Application Support/oab-computer`, `$XDG_STATE_HOME/oab-computer` | +| Keychain service (attach secrets) | `dev.openab.instance-mcp.grant` | `dev.openab.computer.grant` | +| Environment variables | `OAB_INSTANCE_MCP_*` | `OAB_COMPUTER_*` | +| Release assets | `oab-instance-mcp-VERSION-*` | `oab-computer-VERSION-*` | + +To upgrade: + +1. Install 0.12.0 with the new installer (the `.pkg`, `install-prebuilt.sh`, or `install-linux.sh` from the + new Linux tarball). The installer only warns when it finds the old agent. It never stops or removes it. +2. macOS: grant Screen Recording and Accessibility again (and Full Disk Access if you use it). TCC + grants are keyed to the bundle id, which changed. To drop the stale entries, run + `tccutil reset ScreenCapture dev.openab.instance-mcp` and + `tccutil reset Accessibility dev.openab.instance-mcp` (and `tccutil reset SystemPolicyAllFiles dev.openab.instance-mcp` for Full Disk Access). +3. The bearer token is copied automatically from `~/.config/oab-instance-mcp/token` to + `~/.config/oab-computer/token`, mode 600, and the old file stays in place. macOS: the copy happens when the + new config directory is first created. Linux: `install-linux.sh` copies whenever + `~/.config/oab-computer/token` does not exist yet. If `~/.config/oab-computer` already exists on macOS, nothing is copied and a new token is generated when the token file is missing. An existing new token is never overwritten. On Linux a new token is generated only when neither file exists. A user who never had an old token gets a new one and must update their clients. +4. Not migrated, so redo them by hand, but only after the new installer or app has run once so that `~/.config/oab-computer` exists: switchboard settings (`switchboard.url`, `switchboard.secret`, + `switchboard.profile`, `switchboard.secure-set-value`), saved grants and their Keychain secrets (lend again), + logs and audit files (on Linux, grants and audit under `~/.local/state/oab-instance-mcp` stay there too). You may copy the switchboard files, logs and audit files from the old directory + if you want them. Do not copy `grants.json`: the grant secrets stay under the old Keychain service, so + lend each grant again. +5. Remove the old agent. It still holds port 8795 and fights the new one. + + macOS: + + ```sh + launchctl bootout gui/$(id -u)/dev.openab.instance-mcp + rm ~/Library/LaunchAgents/dev.openab.instance-mcp.plist + # only if you used the Playwright agent: + launchctl bootout gui/$(id -u)/dev.openab.instance-mcp.pw-mcp + rm ~/Library/LaunchAgents/dev.openab.instance-mcp.pw-mcp.plist + # then restart the new agent, which could not bind port 8795 while the old one held it: + launchctl kickstart -k gui/$(id -u)/dev.openab.computer + ``` + + If you used the Playwright agent, install the new one from a clone of this repo (`poc/pw-mcp/install.sh`), + then run the main installer (the `.pkg` or `install-prebuilt.sh`) again. The installer adds the browser + upstream to the agent only when the new Playwright agent is already loaded, and a restart alone does not. + + Linux: + + ```sh + systemctl --user disable --now oab-instance-mcp.service + rm ~/.config/systemd/user/oab-instance-mcp.service + # only if you used the Playwright unit (not on --no-browser nodes): + systemctl --user disable --now oab-pw-mcp.service + rm ~/.config/systemd/user/oab-pw-mcp.service + systemctl --user daemon-reload + # then restart the new unit, which could not bind port 8795 while the old one held it: + systemctl --user restart oab-computer.service + ``` + + After the new agent works you can delete the old directories. + ## Build & test (on the build Mac; the laptop never compiles Swift) ```sh -rsync -a --delete --exclude .build --exclude .git ./ :~/src/oab-instance-mcp/ -ssh 'cd ~/src/oab-instance-mcp && swift build -c release && swift test' -ssh 'cd ~/src/oab-instance-mcp && bash scripts/smoke.sh' # loopback, every step time-bounded +rsync -a --delete --exclude .build --exclude .git ./ :~/src/oab-computer/ +ssh 'cd ~/src/oab-computer && swift build -c release && swift test' +ssh 'cd ~/src/oab-computer && bash scripts/smoke.sh' # loopback, every step time-bounded ``` `~/src` is on the internal disk on purpose: `~/build` is a RAID symlink and LaunchAgents cannot @@ -556,26 +624,26 @@ read the RAID (TCC on external volumes). ## Deploy (run on the target) ```sh -ssh 'cd ~/src/oab-instance-mcp && \ +ssh 'cd ~/src/oab-computer && \ KEYCHAIN=~/Library/Keychains/signing.keychain-db KEYCHAIN_PASSWORD_FILE=~/.config/signing/keychain-password \ bash scripts/deploy.sh you@example.com ' ``` -`deploy.sh` wraps the binary in `~/.local/oab-instance-mcp/oab-instance-mcp.app` (bundle id -`dev.openab.instance-mcp`) so TCC grants bind to a stable identity, signs it with the -given Apple Development identity (any cert of yours; TCC grants are keyed on identity + bundle id, so keep using the same one), installs LaunchAgent `dev.openab.instance-mcp` in `gui/501`, +`deploy.sh` wraps the binary in `~/.local/oab-computer/oab-computer.app` (bundle id +`dev.openab.computer`) so TCC grants bind to a stable identity, signs it with the +given Apple Development identity (any cert of yours; TCC grants are keyed on identity + bundle id, so keep using the same one), installs LaunchAgent `dev.openab.computer` in `gui/501`, and runs `tailscale serve --bg --https=8444 http://127.0.0.1:8795`. Then, once, on the Mac's own screen, System Settings → Privacy & Security: -- Screen & System Audio Recording → enable **OpenAB Computer** (older builds: `oab-instance-mcp`) -- Accessibility → enable **OpenAB Computer** (older builds: `oab-instance-mcp`) +- Screen & System Audio Recording → enable **OpenAB Computer** +- Accessibility → enable **OpenAB Computer** -From 0.9.0 the app shows as **OpenAB Computer** in System Settings, Finder and the menu bar. Older -docs and earlier builds call it **oab-instance-mcp**; it is the same app (bundle id -`dev.openab.instance-mcp`), so grants made under the old name carry over. +The app shows as **OpenAB Computer** in System Settings, Finder and the menu bar. Its bundle id is +`dev.openab.computer` since 0.12.0 (earlier builds used `dev.openab.instance-mcp`), so grants made to +a build before 0.12.0 do not carry over. -then `launchctl kickstart -k gui/501/dev.openab.instance-mcp`. `sys_info` reports both as `true` when +then `launchctl kickstart -k gui/501/dev.openab.computer`. `sys_info` reports both as `true` when done. Grants survive re-signing with the same identity + bundle id (verified across 0.1.0→0.2.0). Also on the Mac: `sudo pmset -a displaysleep 0`. With display sleep on, an idle Mac returns black @@ -622,13 +690,13 @@ and the profiles in [docs/tool-profiles.md](docs/tool-profiles.md). The menu off ## Operate ```sh -ssh 'launchctl print gui/501/dev.openab.instance-mcp | grep -E "state|pid"' -ssh 'tail -20 ~/Library/Logs/oab-instance-mcp/agent.log' # one line per session open / tools/call / deny -ssh 'launchctl kickstart -k gui/501/dev.openab.instance-mcp' # restart +ssh 'launchctl print gui/501/dev.openab.computer | grep -E "state|pid"' +ssh 'tail -20 ~/Library/Logs/oab-computer/agent.log' # one line per session open / tools/call / deny +ssh 'launchctl kickstart -k gui/501/dev.openab.computer' # restart ssh '/Applications/Tailscale.app/Contents/MacOS/Tailscale serve status' curl -s https://..ts.net:8444/healthz -ssh 'ls -lt ~/Library/Logs/oab-instance-mcp/jobs/' # exec_start job logs (.out/.err), GC'd after 7 days -ssh 'tail -f ~/Library/Logs/oab-instance-mcp/jobs/.out' # follow a background build live +ssh 'ls -lt ~/Library/Logs/oab-computer/jobs/' # exec_start job logs (.out/.err), GC'd after 7 days +ssh 'tail -f ~/Library/Logs/oab-computer/jobs/.out' # follow a background build live ``` Measured from the laptop: `sys_info` 0.75 s, `exec` 0.47 s, a 1 s exec timeout returns in 1.6 s. diff --git a/Sources/InstanceMCPCore/AttachManager.swift b/Sources/InstanceMCPCore/AttachManager.swift index c18d646..12d311c 100644 --- a/Sources/InstanceMCPCore/AttachManager.swift +++ b/Sources/InstanceMCPCore/AttachManager.swift @@ -5,7 +5,7 @@ import Foundation /// A grant is created by a human through `POST /attach` (from OpenAB Connect or /// Remote, or a curl) and lives **here**, on the Mac. The granting device does not /// need to stay online; the Mac dials, redials and gives up on its own. Design: -/// instance-mcp `docs/requirements/reverse-attach.md`, "How a grant works". +/// openab-computer `docs/requirements/reverse-attach.md`, "How a grant works". /// /// One grant per (runtime, session): a new one for the same target replaces the /// old, which is also how renewal works. The profile is fixed for a grant's life. diff --git a/Sources/InstanceMCPCore/FirstRunSetup.swift b/Sources/InstanceMCPCore/FirstRunSetup.swift index 2c4c435..c0ee898 100644 --- a/Sources/InstanceMCPCore/FirstRunSetup.swift +++ b/Sources/InstanceMCPCore/FirstRunSetup.swift @@ -38,21 +38,21 @@ public enum FirstRunSetup { [legacyTokenPath(home: home), oldestTokenPath(home: home)] } - /// `~/.config/oab-instance-mcp`: the directory the installer and the installed agent read. A no-flag launch + /// `~/.config/oab-computer`: the directory the installer and the installed agent read. A no-flag launch /// under a given home uses it for the switchboard config as well. public static func configDirectory(home: String) -> String { - home + "/.config/oab-instance-mcp" + home + "/.config/oab-computer" } /// The reverse-attach grant record under `home`: the file FileGrantStore.defaultURL names for the account. public static func grantsURL(home: String) -> URL { - URL(fileURLWithPath: home + "/Library/Application Support/oab-instance-mcp/grants.json") + URL(fileURLWithPath: home + "/Library/Application Support/oab-computer/grants.json") } /// The agent log under `home`. The menu's Open Log item opens it, and a LaunchAgent writes it through /// StandardErrorPath. public static func logPath(home: String) -> String { - home + "/Library/Logs/oab-instance-mcp/agent.log" + home + "/Library/Logs/oab-computer/agent.log" } /// Sends this process's stderr to `path` (append, mode 644), as a LaunchAgent's StandardErrorPath does. A Finder @@ -91,7 +91,7 @@ public enum FirstRunSetup { /// Shown in an alert when the no-flag launch finds its port taken, for example by the installed agent. public static func portInUseMessage(host: String, port: UInt16) -> String { - "Port \(port) on \(host) is already in use, probably by the installed oab-instance-mcp agent. " + + "Port \(port) on \(host) is already in use, probably by the installed oab-computer agent. " + "This app did not start. Quit the other process, then open the app again." } @@ -275,7 +275,7 @@ public enum FirstRunSetup { /// and exits 0, so the status could never be parsed. Measured on two Macs: only that variable decides it. static func tailscaleEnvironment(_ base: [String: String]) -> [String: String] { var env = base - if (env["TERM_PROGRAM"] ?? "").isEmpty { env["TERM_PROGRAM"] = "oab-instance-mcp" } + if (env["TERM_PROGRAM"] ?? "").isEmpty { env["TERM_PROGRAM"] = "oab-computer" } return env } diff --git a/Sources/InstanceMCPCore/GrantStore.swift b/Sources/InstanceMCPCore/GrantStore.swift index cc31d07..c9e89c0 100644 --- a/Sources/InstanceMCPCore/GrantStore.swift +++ b/Sources/InstanceMCPCore/GrantStore.swift @@ -9,10 +9,10 @@ import Security /// /// Split by sensitivity: /// - the **record** (id, runtime, session, profile, principal, dates) is JSON in -/// `~/Library/Application Support/oab-instance-mcp/grants.json`, mode 600 in a +/// `~/Library/Application Support/oab-computer/grants.json`, mode 600 in a /// 0700 directory — inspectable, no secret in it; /// - the **attach secret** is a generic password in the login Keychain, service -/// `dev.openab.instance-mcp.grant`, account = grant id, accessible only after +/// `dev.openab.computer.grant`, account = grant id, accessible only after /// first unlock on this device. The Keychain ACL binds it to this code-signing /// identity, like the TCC grants. /// @@ -90,7 +90,7 @@ public final class FileGrantStore: GrantStore, @unchecked Sendable { public static var defaultURL: URL { FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/oab-instance-mcp/grants.json") + .appendingPathComponent("Library/Application Support/oab-computer/grants.json") } public init(url: URL = FileGrantStore.defaultURL, secrets: SecretStore = KeychainSecretStore(), @@ -166,7 +166,7 @@ public final class FileGrantStore: GrantStore, @unchecked Sendable { } public struct KeychainSecretStore: SecretStore { - public static let service = "dev.openab.instance-mcp.grant" + public static let service = "dev.openab.computer.grant" public init() {} private func query(_ account: String) -> [String: Any] { @@ -180,7 +180,7 @@ public struct KeychainSecretStore: SecretStore { var add = query(account) add[kSecValueData as String] = Data(secret.utf8) add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - add[kSecAttrLabel as String] = "oab-instance-mcp attach grant" + add[kSecAttrLabel as String] = "oab-computer attach grant" return SecItemAdd(add as CFDictionary, nil) == errSecSuccess } diff --git a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift index 5a0e035..2389371 100644 --- a/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift +++ b/Sources/InstanceMCPCore/MCPHTTPEndpoint.swift @@ -157,7 +157,7 @@ extension MCPHTTPEndpoint { public final class LoopbackHTTPServer: @unchecked Sendable { private let listener: NWListener private let endpoint: MCPHTTPEndpoint - private let queue = DispatchQueue(label: "oab-instance-mcp.http", qos: .userInitiated) + private let queue = DispatchQueue(label: "oab-computer.http", qos: .userInitiated) private let log: @Sendable (String) -> Void public init(host: String, port: UInt16, endpoint: MCPHTTPEndpoint, log: @escaping @Sendable (String) -> Void) throws { diff --git a/Sources/InstanceMCPCore/MenuModel.swift b/Sources/InstanceMCPCore/MenuModel.swift index 149fa4c..bde04c1 100644 --- a/Sources/InstanceMCPCore/MenuModel.swift +++ b/Sources/InstanceMCPCore/MenuModel.swift @@ -10,7 +10,7 @@ public enum LaunchdJob { /// The variable is inherited by every child of a job, so a shell inside another LaunchAgent (an Actions runner, a /// coding agent) carries that job's label. Only this app's own label counts: booting out any other would stop an /// unrelated service, and a restart would wait for a KeepAlive that does not exist. - public static let ownLabel = "dev.openab.instance-mcp" + public static let ownLabel = "dev.openab.computer" /// A child of this app (a second copy started through the exec tool) inherits the label too, but its parent is the /// app, not launchd (pid 1), so the parent pid must be 1 as well. diff --git a/Sources/InstanceMCPCore/ProfileAudit.swift b/Sources/InstanceMCPCore/ProfileAudit.swift index 1febc72..5c62e7a 100644 --- a/Sources/InstanceMCPCore/ProfileAudit.swift +++ b/Sources/InstanceMCPCore/ProfileAudit.swift @@ -6,7 +6,7 @@ import Foundation /// `desktop` and the daemon serves `observe` instead, it appends one JSON line here /// as well as logging it. The file is append-only and separate from the daemon log, /// so the record survives log rotation and is easy to inspect: -/// `~/Library/Application Support/oab-instance-mcp/audit.jsonl`, mode 600 in a 0700 +/// `~/Library/Application Support/oab-computer/audit.jsonl`, mode 600 in a 0700 /// directory, beside `grants.json`. The Linux node writes the same record shape. /// /// Writing is best-effort: a failure is logged and never blocks the grant. @@ -41,7 +41,7 @@ public final class FileAuditLog: AuditLog, @unchecked Sendable { public static var defaultURL: URL { FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/oab-instance-mcp/audit.jsonl") + .appendingPathComponent("Library/Application Support/oab-computer/audit.jsonl") } public init(url: URL = FileAuditLog.defaultURL, log: @escaping @Sendable (String) -> Void = { _ in }) { diff --git a/Sources/InstanceMCPCore/ReverseAttachClient.swift b/Sources/InstanceMCPCore/ReverseAttachClient.swift index c1b551d..72bf5be 100644 --- a/Sources/InstanceMCPCore/ReverseAttachClient.swift +++ b/Sources/InstanceMCPCore/ReverseAttachClient.swift @@ -79,7 +79,7 @@ public actor ReverseAttachClient { } public struct Config: Sendable { - /// `ws://` or `wss://` base of the runtime, e.g. `ws://100.111.174.31:8090`. + /// `ws://` or `wss://` base of the runtime, e.g. `ws://100.64.0.2:8090`. public var runtime: URL public var session: String public var secret: String diff --git a/Sources/InstanceMCPCore/SwitchboardConfig.swift b/Sources/InstanceMCPCore/SwitchboardConfig.swift index ba9344e..b874257 100644 --- a/Sources/InstanceMCPCore/SwitchboardConfig.swift +++ b/Sources/InstanceMCPCore/SwitchboardConfig.swift @@ -156,9 +156,9 @@ public struct SwitchboardConfigStore: Sendable { } } - /// `~/.config/oab-instance-mcp`, the directory the installer reads. + /// `~/.config/oab-computer`, the directory the installer reads. public static var defaultDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/oab-instance-mcp") + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/oab-computer") } public let directory: URL diff --git a/Sources/InstanceMCPCore/ToolProfile.swift b/Sources/InstanceMCPCore/ToolProfile.swift index 6178dbf..86574df 100644 --- a/Sources/InstanceMCPCore/ToolProfile.swift +++ b/Sources/InstanceMCPCore/ToolProfile.swift @@ -2,7 +2,7 @@ import Foundation /// Which tools a connection may see and call. Chosen by *this* side — the Mac is /// the MCP server, so scoping is a per-connection tool list here, never MCP -/// parsing in a proxy (instance-mcp `docs/adr/reverse-attach.md`). +/// parsing in a proxy (openab-computer `docs/adr/reverse-attach.md`). /// /// Two profiles (openab-computer#59), named after the one boundary that is real: /// shell-equivalent or not. diff --git a/Sources/InstanceMCPCore/Tools/AsyncExecTools.swift b/Sources/InstanceMCPCore/Tools/AsyncExecTools.swift index 22c5bb4..4e0c5a1 100644 --- a/Sources/InstanceMCPCore/Tools/AsyncExecTools.swift +++ b/Sources/InstanceMCPCore/Tools/AsyncExecTools.swift @@ -76,7 +76,7 @@ public struct ExecPollTool: Tool { byte offsets returned by the previous poll. When `state` is `exited` or `killed`, \ `exit_code` is set and no more output will appear. Output is read from the job's log \ files (never truncated); terminal-job metadata is retained ~10 min, and the log files \ - themselves persist on disk (~/Library/Logs/oab-instance-mcp/jobs/.out/.err). + themselves persist on disk (~/Library/Logs/oab-computer/jobs/.out/.err). """ public let inputSchema: JSONValue = [ "type": "object", @@ -92,7 +92,7 @@ public struct ExecPollTool: Tool { public func call(arguments: JSONValue) async throws -> ToolResult { guard let id = arguments["job_id"]?.stringValue else { throw JSONRPCError.invalidParams("job_id is required") } guard let job = await JobRegistry.shared.job(id) else { - return .error("unknown job_id: \(id) (its metadata may have been garbage-collected; the log files may still exist under ~/Library/Logs/oab-instance-mcp/jobs/)") + return .error("unknown job_id: \(id) (its metadata may have been garbage-collected; the log files may still exist under ~/Library/Logs/oab-computer/jobs/)") } let outSince = arguments["stdout_since"]?.intValue ?? 0 let errSince = arguments["stderr_since"]?.intValue ?? 0 diff --git a/Sources/InstanceMCPCore/Tools/ExecSupport.swift b/Sources/InstanceMCPCore/Tools/ExecSupport.swift index 154eaa1..b2830de 100644 --- a/Sources/InstanceMCPCore/Tools/ExecSupport.swift +++ b/Sources/InstanceMCPCore/Tools/ExecSupport.swift @@ -69,7 +69,7 @@ public enum ExecCwd { static func externalVolumeHint(_ path: String, verb: String) -> String { "cwd \(path) is on an external volume and \(verb). If this daemon runs as a LaunchAgent, " - + "macOS TCC blocks external-volume access unless the app bundle (dev.openab.instance-mcp) is granted " + + "macOS TCC blocks external-volume access unless the app bundle (dev.openab.computer) is granted " + "Full Disk Access in System Settings → Privacy & Security → Full Disk Access. " + "Grant it and restart the agent, or use an internal-disk path." } diff --git a/Sources/InstanceMCPCore/Tools/ExecTool.swift b/Sources/InstanceMCPCore/Tools/ExecTool.swift index 6daeae9..e4e38e6 100644 --- a/Sources/InstanceMCPCore/Tools/ExecTool.swift +++ b/Sources/InstanceMCPCore/Tools/ExecTool.swift @@ -88,7 +88,7 @@ public struct ExecTool: Tool { let pid = proc.pid let collector = OutputCollector(cap: cap) - let ioQueue = DispatchQueue(label: "oab-instance-mcp.exec.io", attributes: .concurrent) + let ioQueue = DispatchQueue(label: "oab-computer.exec.io", attributes: .concurrent) let group = DispatchGroup() for (fd, stream) in [(proc.stdoutFD, 0), (proc.stderrFD, 1)] { group.enter() diff --git a/Sources/InstanceMCPCore/Tools/Input.swift b/Sources/InstanceMCPCore/Tools/Input.swift index bd04de6..f1a94a9 100644 --- a/Sources/InstanceMCPCore/Tools/Input.swift +++ b/Sources/InstanceMCPCore/Tools/Input.swift @@ -9,7 +9,7 @@ enum Input { static func requireAccessibility() throws { guard AXIsProcessTrusted() else { throw ToolError("Accessibility not granted: input events would be silently dropped. " + - "Grant Accessibility to OpenAB Computer (oab-instance-mcp) in System Settings → Privacy & Security → Accessibility, then restart the agent.") + "Grant Accessibility to OpenAB Computer (oab-computer) in System Settings → Privacy & Security → Accessibility, then restart the agent.") } } diff --git a/Sources/InstanceMCPCore/Tools/JobRegistry.swift b/Sources/InstanceMCPCore/Tools/JobRegistry.swift index be8f931..f2cac2d 100644 --- a/Sources/InstanceMCPCore/Tools/JobRegistry.swift +++ b/Sources/InstanceMCPCore/Tools/JobRegistry.swift @@ -84,7 +84,7 @@ public actor JobRegistry { private let maxAgeDays: Double // startup GC threshold for log *files* public init(logDir: String? = nil, retention: TimeInterval = 600, maxJobs: Int = 64, maxAgeDays: Double = 7) { - self.logDir = logDir ?? (NSHomeDirectory() + "/Library/Logs/oab-instance-mcp/jobs") + self.logDir = logDir ?? (NSHomeDirectory() + "/Library/Logs/oab-computer/jobs") self.retention = retention self.maxJobs = maxJobs self.maxAgeDays = maxAgeDays @@ -180,7 +180,7 @@ public actor JobRegistry { /// Drain both pipes into the job's log files and reap the child on a detached task, so /// the spawning MCP call returns immediately. A timeout (> 0) kills the process group. private nonisolated func superviseDetached(job: Job, proc: SpawnedProcess, timeout: TimeInterval, outFile: Int32, errFile: Int32) { - let ioQueue = DispatchQueue(label: "oab-instance-mcp.job.io", attributes: .concurrent) + let ioQueue = DispatchQueue(label: "oab-computer.job.io", attributes: .concurrent) let group = DispatchGroup() for (readFD, writeFD) in [(proc.stdoutFD, outFile), (proc.stderrFD, errFile)] { group.enter() diff --git a/Sources/InstanceMCPCore/Tools/ScreenshotTool.swift b/Sources/InstanceMCPCore/Tools/ScreenshotTool.swift index 2f83056..b4fa748 100644 --- a/Sources/InstanceMCPCore/Tools/ScreenshotTool.swift +++ b/Sources/InstanceMCPCore/Tools/ScreenshotTool.swift @@ -43,7 +43,7 @@ public struct ScreenshotTool: Tool { content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) } catch { throw ToolError("ScreenCaptureKit unavailable: \(error.localizedDescription). " + - "Grant Screen Recording to OpenAB Computer (oab-instance-mcp) in System Settings → Privacy & Security, then restart the agent.") + "Grant Screen Recording to OpenAB Computer (oab-computer) in System Settings → Privacy & Security, then restart the agent.") } // Main display first so index 0 is predictable; shared with mouse and ui_snapshot. let ordered = DisplayOrder.sorted(content.displays, mainID: CGMainDisplayID()) { $0.displayID } diff --git a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift index f150415..e946b04 100644 --- a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift +++ b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift @@ -59,7 +59,7 @@ public struct SysInfoTool: Tool { let tail = tailnetAddresses() let structured: JSONValue = [ - "agent": ["name": "oab-instance-mcp", "version": .string(agentVersion), "pid": .number(Double(pi.processIdentifier))], + "agent": ["name": "oab-computer", "version": .string(agentVersion), "pid": .number(Double(pi.processIdentifier))], "host": .string(host), "os": .string("macOS \(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"), "hardware": .object(hw), @@ -85,9 +85,9 @@ public struct SysInfoTool: Tool { }.joined(separator: ", ")) lines.append("tailscale: \(tail.isEmpty ? "none" : tail.joined(separator: ", "))") lines.append("permissions: screen_recording=\(screenRecording) accessibility=\(accessibility) full_disk_access=\(fullDiskAccessState)") - if !screenRecording { lines.append("→ screenshot will fail until Screen Recording is granted to OpenAB Computer (oab-instance-mcp)") } - if !accessibility { lines.append("→ mouse/key/ui_snapshot/ui_action will fail until Accessibility is granted to OpenAB Computer (oab-instance-mcp)") } - if !fullDiskAccess { lines.append("→ protected Mail/Messages/Safari files will fail until Full Disk Access is granted to OpenAB Computer (oab-instance-mcp)") } + if !screenRecording { lines.append("→ screenshot will fail until Screen Recording is granted to OpenAB Computer (oab-computer)") } + if !accessibility { lines.append("→ mouse/key/ui_snapshot/ui_action will fail until Accessibility is granted to OpenAB Computer (oab-computer)") } + if !fullDiskAccess { lines.append("→ protected Mail/Messages/Safari files will fail until Full Disk Access is granted to OpenAB Computer (oab-computer)") } lines.append("agent \(agentVersion)") return ToolResult(content: [.text(lines.joined(separator: "\n"))], structured: structured) } diff --git a/Sources/InstanceMCPCore/Tools/UIActionTool.swift b/Sources/InstanceMCPCore/Tools/UIActionTool.swift index 055b366..95dd886 100644 --- a/Sources/InstanceMCPCore/Tools/UIActionTool.swift +++ b/Sources/InstanceMCPCore/Tools/UIActionTool.swift @@ -47,7 +47,7 @@ public struct UIActionTool: Tool { /// Error texts, pinned by tests. None of them ever contains the input `value`. public enum Errors { - public static let notTrusted = "Accessibility not granted: ui_action cannot act on other apps' windows. Grant Accessibility to OpenAB Computer (oab-instance-mcp, bundle id dev.openab.instance-mcp) in System Settings → Privacy & Security → Accessibility, then restart the agent." + public static let notTrusted = "Accessibility not granted: ui_action cannot act on other apps' windows. Grant Accessibility to OpenAB Computer (oab-computer, bundle id dev.openab.computer) in System Settings → Privacy & Security → Accessibility, then restart the agent." public static let selfApp = "ui_action cannot act on OpenAB Computer's own windows" public static let busy = "another ui_action is still running and the timeout_ms budget ran out waiting for it; nothing was done. Retry" public static func staleSnapshot(_ id: String) -> String { diff --git a/Sources/InstanceMCPCore/Tools/UISnapshotTool.swift b/Sources/InstanceMCPCore/Tools/UISnapshotTool.swift index 1a78dd7..29cea07 100644 --- a/Sources/InstanceMCPCore/Tools/UISnapshotTool.swift +++ b/Sources/InstanceMCPCore/Tools/UISnapshotTool.swift @@ -41,7 +41,7 @@ public struct UISnapshotTool: Tool { /// Error texts, pinned by tests. public enum Errors { - public static let notTrusted = "Accessibility not granted: ui_snapshot cannot read other apps' windows. Grant Accessibility to OpenAB Computer (oab-instance-mcp, bundle id dev.openab.instance-mcp) in System Settings → Privacy & Security → Accessibility, then restart the agent." + public static let notTrusted = "Accessibility not granted: ui_snapshot cannot read other apps' windows. Grant Accessibility to OpenAB Computer (oab-computer, bundle id dev.openab.computer) in System Settings → Privacy & Security → Accessibility, then restart the agent." public static let noFrontmost = "no frontmost app (the screen may be locked or at the login window); pass app or pid" public static let budgetSpent = "the timeout_ms budget ran out before the target app was chosen; raise timeout_ms, or pass app or pid" public static let selfApp = "ui_snapshot cannot read OpenAB Computer's own windows" diff --git a/Sources/InstanceMCPCore/UpstreamMCP.swift b/Sources/InstanceMCPCore/UpstreamMCP.swift index 84c8a96..1bc70f9 100644 --- a/Sources/InstanceMCPCore/UpstreamMCP.swift +++ b/Sources/InstanceMCPCore/UpstreamMCP.swift @@ -94,7 +94,7 @@ public actor UpstreamMCP { let (status, body, headers) = try await postRaw([ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": ["protocolVersion": "2025-06-18", "capabilities": [:], - "clientInfo": ["name": "oab-instance-mcp", "version": "upstream"]], + "clientInfo": ["name": "oab-computer", "version": "upstream"]], ]) guard (200..<300).contains(status) else { throw UpstreamError.http(status, body) } sessionID = headers.first { $0.key.lowercased() == "mcp-session-id" }?.value diff --git a/Sources/oab-instance-mcp/PermissionSetupWindowController.swift b/Sources/oab-computer/PermissionSetupWindowController.swift similarity index 100% rename from Sources/oab-instance-mcp/PermissionSetupWindowController.swift rename to Sources/oab-computer/PermissionSetupWindowController.swift diff --git a/Sources/oab-instance-mcp/StatusItemController.swift b/Sources/oab-computer/StatusItemController.swift similarity index 100% rename from Sources/oab-instance-mcp/StatusItemController.swift rename to Sources/oab-computer/StatusItemController.swift diff --git a/Sources/oab-instance-mcp/SwitchboardMenu.swift b/Sources/oab-computer/SwitchboardMenu.swift similarity index 100% rename from Sources/oab-instance-mcp/SwitchboardMenu.swift rename to Sources/oab-computer/SwitchboardMenu.swift diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-computer/main.swift similarity index 97% rename from Sources/oab-instance-mcp/main.swift rename to Sources/oab-computer/main.swift index 02e078f..980fc1c 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-computer/main.swift @@ -39,9 +39,9 @@ struct Options { func usage() -> Never { print(""" - oab-instance-mcp \(version) — MCP server exposing this Mac (exec / screenshot / ui_snapshot / ui_action / mouse / key / osascript / sys_info) + oab-computer \(version) — MCP server exposing this Mac (exec / screenshot / ui_snapshot / ui_action / mouse / key / osascript / sys_info) - USAGE: oab-instance-mcp [--host 127.0.0.1] [--port 8795] [--path /mcp] + USAGE: oab-computer [--host 127.0.0.1] [--port 8795] [--path /mcp] [--allow-login ]... [--token | --token-file ] [--insecure-local] [--quiet] [--menu-bar] [--public-url ] @@ -66,7 +66,7 @@ func usage() -> Never { Mac to one openab-pty session (this Mac dials the pod; see the ADR). --no-grant-persistence Keep reverse-attach grants in memory only. By default live grants are - saved (record: ~/Library/Application Support/oab-instance-mcp/grants.json, + saved (record: ~/Library/Application Support/oab-computer/grants.json, mode 600; secret: login Keychain) and re-dialled after a restart. --switchboard Dial an openab-sb switchboard and serve this computer's tools on that socket, so @@ -175,11 +175,11 @@ if firstRunLaunch { FirstRunSetup.redirectStderr(toLogAt: FirstRunSetup.logPath(home: home)) #if DEBUG // Test seam, compiled into debug builds only (swift test). Release builds have no override and always use 8795. - if let raw = ProcessInfo.processInfo.environment["OAB_INSTANCE_MCP_FIRST_RUN_PORT"] { + if let raw = ProcessInfo.processInfo.environment["OAB_COMPUTER_FIRST_RUN_PORT"] { if let p = Int(raw), (1...65535).contains(p) { opts.port = UInt16(p) } else { - fputs("no-flag launch: OAB_INSTANCE_MCP_FIRST_RUN_PORT=\(raw) rejected (not 1 to 65535); using \(opts.port)\n", stderr) + fputs("no-flag launch: OAB_COMPUTER_FIRST_RUN_PORT=\(raw) rejected (not 1 to 65535); using \(opts.port)\n", stderr) } } #endif @@ -200,7 +200,7 @@ if firstRunLaunch { firstRunNotice = plan.notice firstRunLines = plan.logLines } catch { - showFirstRunAlert("Could not prepare the token file: \(error). Quit any other oab-instance-mcp that is running, " + + showFirstRunAlert("Could not prepare the token file: \(error). Quit any other oab-computer that is running, " + "remove \(FirstRunSetup.lockPath(for: FirstRunSetup.tokenPath(home: home))) if it is left over from a crash, " + "then open the app again. Details are in \(FirstRunSetup.logPath(home: home)).") exit(73) @@ -255,7 +255,7 @@ let log: @Sendable (String) -> Void = { msg in } let server = MCPServer( - name: "oab-instance-mcp", + name: "oab-computer", version: version, instructions: """ You are operating a real Mac (\(Host.current().localizedName ?? "unknown")) through its logged-in \ @@ -301,7 +301,7 @@ let attachManager: AttachManager? = opts.attach // be read from Tailscale now; a configured allow list alone is not proof that a login exists at this launch. // (Skipped under the debug-only test switch below, so test-launched binaries never run the Tailscale CLI.) #if DEBUG -let exposureProbeSkipped = ProcessInfo.processInfo.environment["OAB_INSTANCE_MCP_NO_TAILNET_EXPOSURE"] != nil +let exposureProbeSkipped = ProcessInfo.processInfo.environment["OAB_COMPUTER_NO_TAILNET_EXPOSURE"] != nil #else let exposureProbeSkipped = false #endif @@ -319,7 +319,7 @@ let installerOwnsOtherPort = !firstRunLaunch && TailnetExposure.installerOwnsOth // Debug builds only: the process tests set an environment variable so a test-launched binary never touches Tailscale. // A release build has no such switch, so nothing in the environment can skip the no-login withdrawal below. #if DEBUG -let tailnetExposureDisabledForTests = ProcessInfo.processInfo.environment["OAB_INSTANCE_MCP_NO_TAILNET_EXPOSURE"] != nil +let tailnetExposureDisabledForTests = ProcessInfo.processInfo.environment["OAB_COMPUTER_NO_TAILNET_EXPOSURE"] != nil #else let tailnetExposureDisabledForTests = false #endif @@ -359,7 +359,7 @@ do { fputs("failed to start listener: \(error)\n", stderr); exit(2) } let startupPermissions = PermissionProbe.current() -log("oab-instance-mcp \(version) starting on http://\(opts.host):\(opts.port)\(opts.path) " + +log("oab-computer \(version) starting on http://\(opts.host):\(opts.port)\(opts.path) " + "auth=[logins:\(opts.allowLogins.sorted().joined(separator: ",")) token:\(opts.token != nil) insecure-local:\(opts.insecureLocal)] " + "attach=\(opts.attach) upstreams=[\(opts.upstreams.map { $0.0 }.joined(separator: ","))] " + "screen_recording=\(startupPermissions.screenRecording.isGranted) " + diff --git a/Tests/InstanceMCPCoreTests/FirstRunSetupTests.swift b/Tests/InstanceMCPCoreTests/FirstRunSetupTests.swift index 236d781..919a02c 100644 --- a/Tests/InstanceMCPCoreTests/FirstRunSetupTests.swift +++ b/Tests/InstanceMCPCoreTests/FirstRunSetupTests.swift @@ -309,7 +309,7 @@ final class FirstRunSetupTests: XCTestCase { func testTailscaleLoginIsReadTheSameWayTheInstallerReadsIt() { let json = """ - {"Self":{"DNSName":"mac.tailb836bb.ts.net.","UserID":12345}, + {"Self":{"DNSName":"mac.tail1234.ts.net.","UserID":12345}, "User":{"12345":{"LoginName":"owner@example.com"}}} """ XCTAssertEqual(FirstRunSetup.tailscaleLogin(statusJSON: Data(json.utf8)), "owner@example.com") @@ -332,19 +332,37 @@ final class FirstRunSetupTests: XCTestCase { func testAgentLogIsTheFileTheMenuOpensUnderTheSameHome() { XCTAssertEqual(FirstRunSetup.logPath(home: "/Users/x"), - "/Users/x/Library/Logs/oab-instance-mcp/agent.log") + "/Users/x/Library/Logs/oab-computer/agent.log") XCTAssertEqual(FirstRunSetup.grantsURL(home: "/Users/x").path, - "/Users/x/Library/Application Support/oab-instance-mcp/grants.json") - XCTAssertEqual(FirstRunSetup.configDirectory(home: "/Users/x"), "/Users/x/.config/oab-instance-mcp") + "/Users/x/Library/Application Support/oab-computer/grants.json") + XCTAssertEqual(FirstRunSetup.configDirectory(home: "/Users/x"), "/Users/x/.config/oab-computer") + } + + /// The Swift half of scripts/test-name-consistency.sh: every user-visible name the app derives is + /// the 0.12.0 name, and none is the old one. `legacyTokenPath` is the one deliberate exception. + func testUserVisibleNamesAreOabComputer() { + let names = [ + FirstRunSetup.configDirectory(home: "/h"), + FirstRunSetup.logPath(home: "/h"), + FirstRunSetup.grantsURL(home: "/h").path, + FirstRunSetup.tokenPath(home: "/h"), + KeychainSecretStore.service, + LaunchdJob.ownLabel, + ] + for name in names { + XCTAssertTrue(name.contains("computer"), name) + XCTAssertFalse(name.contains("instance-mcp"), name) + } + XCTAssertEqual(KeychainSecretStore.service, LaunchdJob.ownLabel + ".grant") } - // MARK: flag launch (process level, the built `oab-instance-mcp` binary) + // MARK: flag launch (process level, the built `oab-computer` binary) private func builtBinary() throws -> URL { let dir = Bundle(for: FirstRunSetupTests.self).bundleURL.deletingLastPathComponent() - let url = dir.appendingPathComponent("oab-instance-mcp") + let url = dir.appendingPathComponent("oab-computer") guard FileManager.default.isExecutableFile(atPath: url.path) else { - throw XCTSkip("oab-instance-mcp is not built next to the test bundle: \(url.path)") + throw XCTSkip("oab-computer is not built next to the test bundle: \(url.path)") } return url } @@ -377,7 +395,7 @@ final class FirstRunSetupTests: XCTestCase { "--no-attach", "--no-grant-persistence", "--quiet"] var environment = ProcessInfo.processInfo.environment environment["HOME"] = home - environment["OAB_INSTANCE_MCP_NO_TAILNET_EXPOSURE"] = "1" + environment["OAB_COMPUTER_NO_TAILNET_EXPOSURE"] = "1" child.environment = environment child.standardOutput = FileHandle.nullDevice child.standardError = FileHandle.nullDevice @@ -420,9 +438,9 @@ final class FirstRunSetupTests: XCTestCase { var environment = ProcessInfo.processInfo.environment environment["HOME"] = home // Moves only the no-flag launch's port, for the free-port test. Unset means the default 8795. - environment["OAB_INSTANCE_MCP_FIRST_RUN_PORT"] = port.map(String.init) + environment["OAB_COMPUTER_FIRST_RUN_PORT"] = port.map(String.init) // Structural guard: the test-launched binary builds no TailnetExposure, so it can never run `tailscale serve`. - environment["OAB_INSTANCE_MCP_NO_TAILNET_EXPOSURE"] = "1" + environment["OAB_COMPUTER_NO_TAILNET_EXPOSURE"] = "1" let child = Process() child.executableURL = binary child.arguments = [] diff --git a/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift b/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift index a5f82e4..e28d03d 100644 --- a/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift +++ b/Tests/InstanceMCPCoreTests/InstanceMCPCoreTests.swift @@ -139,9 +139,9 @@ final class HTTPParserTests: XCTestCase { } func testHeaderKeysLowercased() { - let d = Data("GET / HTTP/1.1\r\nTailscale-User-Login: A@B.com\r\n\r\n".utf8) + let d = Data("GET / HTTP/1.1\r\nTailscale-User-Login: a@example.com\r\n\r\n".utf8) guard case .request(let r, _) = HTTPParser.parse(d) else { return XCTFail() } - XCTAssertEqual(r.headers["tailscale-user-login"], "A@B.com") + XCTAssertEqual(r.headers["tailscale-user-login"], "a@example.com") } func testHeaderValueWhitespaceStripped() { @@ -184,8 +184,8 @@ final class AuthPolicyTests: XCTestCase { } func testLoginAllowlistCaseInsensitive() { - let p = AuthPolicy(allowedLogins: ["Pahud@Example.com"]) - XCTAssertEqual(p.decide(headers: ["tailscale-user-login": "pahud@example.com"], remoteIsLoopback: true), .allow(principal: "pahud@example.com")) + let p = AuthPolicy(allowedLogins: ["Owner@Example.com"]) + XCTAssertEqual(p.decide(headers: ["tailscale-user-login": "owner@example.com"], remoteIsLoopback: true), .allow(principal: "owner@example.com")) XCTAssertEqual(p.decide(headers: ["tailscale-user-login": "other@example.com"], remoteIsLoopback: true), .deny(reason: "login other@example.com not allowed")) } diff --git a/Tests/InstanceMCPCoreTests/MenuModelTests.swift b/Tests/InstanceMCPCoreTests/MenuModelTests.swift index bb3b3e3..c5836a4 100644 --- a/Tests/InstanceMCPCoreTests/MenuModelTests.swift +++ b/Tests/InstanceMCPCoreTests/MenuModelTests.swift @@ -44,19 +44,19 @@ final class MenuModelTests: XCTestCase { } func testLaunchdDetectionUsesAJobLabelOnly() { - XCTAssertTrue(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "dev.openab.instance-mcp"], parentPID: 1)) - XCTAssertEqual(LaunchdJob.jobLabel(environment: ["XPC_SERVICE_NAME": "dev.openab.instance-mcp"], parentPID: 1), "dev.openab.instance-mcp") - XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "dev.openab.instance-mcp"], parentPID: 4242), + XCTAssertTrue(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "dev.openab.computer"], parentPID: 1)) + XCTAssertEqual(LaunchdJob.jobLabel(environment: ["XPC_SERVICE_NAME": "dev.openab.computer"], parentPID: 1), "dev.openab.computer") + XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "dev.openab.computer"], parentPID: 4242), "a child of the app inherits the label but its parent is not launchd") XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: [:])) XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "0"]), "a terminal session") XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": ""])) - XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "application.dev.openab.instance-mcp.1234.5678"]), + XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: ["XPC_SERVICE_NAME": "application.dev.openab.computer.1234.5678"]), "an app opened from Finder is not a job that can be booted out") } func testAForeignInheritedJobLabelIsNotThisAppsLaunchdJob() { - let foreign = ["XPC_SERVICE_NAME": "actions.runner.oablab.macmini-oablab"] + let foreign = ["XPC_SERVICE_NAME": "actions.runner.oablab.runner-1"] XCTAssertNil(LaunchdJob.jobLabel(environment: foreign)) XCTAssertFalse(LaunchdJob.isRunByLaunchd(environment: foreign)) // Quit then boots nothing out; it just exits. @@ -110,8 +110,8 @@ final class MenuModelTests: XCTestCase { MenuModel.quit(jobLabel: nil, bootout: { booted.append($0) }, exitProcess: { exits += 1 }) XCTAssertEqual(booted, []) XCTAssertEqual(exits, 1) - MenuModel.quit(jobLabel: "dev.openab.instance-mcp", bootout: { booted.append($0) }, exitProcess: { exits += 1 }) - XCTAssertEqual(booted, ["dev.openab.instance-mcp"]) + MenuModel.quit(jobLabel: "dev.openab.computer", bootout: { booted.append($0) }, exitProcess: { exits += 1 }) + XCTAssertEqual(booted, ["dev.openab.computer"]) XCTAssertEqual(exits, 1) } diff --git a/Tests/InstanceMCPCoreTests/NetworkSectionTests.swift b/Tests/InstanceMCPCoreTests/NetworkSectionTests.swift index 656b4dc..7ed8c39 100644 --- a/Tests/InstanceMCPCoreTests/NetworkSectionTests.swift +++ b/Tests/InstanceMCPCoreTests/NetworkSectionTests.swift @@ -26,7 +26,7 @@ final class NetworkSectionTests: XCTestCase { func testLoopbackHostTable() { for h in ["127.0.0.1", "127.1.2.3", "::1", "[::1]", "localhost", "LocalHost"] { XCTAssertTrue(MenuModel.isLoopbackHost(h), h) } - for h in ["0.0.0.0", "::", "192.168.0.5", "", "127.0.0", "127.0.0.1.5", "128.0.0.1", "example.com", "127.0.0.300"] { + for h in ["0.0.0.0", "::", "192.0.2.5", "", "127.0.0", "127.0.0.1.5", "128.0.0.1", "example.com", "127.0.0.300"] { XCTAssertFalse(MenuModel.isLoopbackHost(h), h) } } @@ -40,7 +40,7 @@ final class NetworkSectionTests: XCTestCase { XCTAssertEqual(open.rows[0].copyValue, "http://0.0.0.0:8795/mcp") XCTAssertEqual(text(section(host: "::1"), .localMCP), "Local MCP: http://[::1]:8795/mcp (this Mac only)") XCTAssertTrue(text(section(host: "::"), .localMCP)!.contains("reachable from other devices")) - XCTAssertFalse(text(section(host: "192.168.0.5"), .localMCP)!.contains("this Mac only")) + XCTAssertFalse(text(section(host: "192.0.2.5"), .localMCP)!.contains("this Mac only")) } func testEachTailnetState() { @@ -136,8 +136,8 @@ final class NetworkSectionTests: XCTestCase { "Browser tools: 127.0.0.1:8794 (separate process, this Mac only)") XCTAssertEqual(text(section(upstreams: [upstream("browser", "http://example.com:8794/mcp")]), .browser), "Browser tools: example.com:8794 (remote)") - XCTAssertEqual(text(section(upstreams: [upstream("browser", "http://192.168.0.9:8794/mcp")]), .browser), - "Browser tools: 192.168.0.9:8794 (remote)") + XCTAssertEqual(text(section(upstreams: [upstream("browser", "http://192.0.2.9:8794/mcp")]), .browser), + "Browser tools: 192.0.2.9:8794 (remote)") } func testReverseAttachRow() { diff --git a/Tests/InstanceMCPCoreTests/PermissionRelaunchTests.swift b/Tests/InstanceMCPCoreTests/PermissionRelaunchTests.swift index 02623b9..84b000d 100644 --- a/Tests/InstanceMCPCoreTests/PermissionRelaunchTests.swift +++ b/Tests/InstanceMCPCoreTests/PermissionRelaunchTests.swift @@ -112,7 +112,7 @@ final class RelaunchPolicyTests: XCTestCase { RelaunchPolicy.decide(child: child, processGranted: processGranted, userConfirmedEnabled: confirmed) } - /// The Muse-style flow: the user turns it on and picks Later in the macOS dialog. The check mark appears and + /// The reported flow: the user turns it on and picks Later in the macOS dialog. The check mark appears and /// nothing relaunches at that moment. func testCheckMarkAppearsWhenTheChildSaysGrantedWithoutAnyRelaunch() { XCTAssertEqual(RelaunchPolicy.shownScreenRecording(process: .denied, child: .granted), .granted) @@ -265,7 +265,7 @@ final class HiddenPermissionCheckTests: XCTestCase { /// Runs the real built executable. Needs the executable target built next to the test bundle. func testBuiltExecutableExitsZeroOrOneWithoutOutput() throws { let dir = Bundle(for: Self.self).bundleURL.deletingLastPathComponent() - let exe = dir.appendingPathComponent("oab-instance-mcp") + let exe = dir.appendingPathComponent("oab-computer") try XCTSkipUnless(FileManager.default.isExecutableFile(atPath: exe.path), "executable not built beside the tests") let p = Process() p.executableURL = exe diff --git a/Tests/InstanceMCPCoreTests/SwitchboardTests.swift b/Tests/InstanceMCPCoreTests/SwitchboardTests.swift index 5b57a77..676191f 100644 --- a/Tests/InstanceMCPCoreTests/SwitchboardTests.swift +++ b/Tests/InstanceMCPCoreTests/SwitchboardTests.swift @@ -35,7 +35,7 @@ final class SwitchboardPolicyTests: XCTestCase { } func testSwitchboardConfigUsesTheURLAsIsAndNeverExpires() { - let url = URL(string: "wss://macmini.tail.ts.net/vm/attach")! + let url = URL(string: "wss://host.tail.ts.net/vm/attach")! let c = ReverseAttachClient.Config.switchboard(url: url, secret: "s", profile: .observe) XCTAssertEqual(c.attachURL, url) XCTAssertEqual(c.kind, .switchboard) @@ -64,8 +64,8 @@ final class SwitchboardConfigTests: XCTestCase { } func testRefusesPlaintextAcrossTheNetwork() { - XCTAssertThrowsError(try Switchboard.validate("ws://100.74.35.49:8790/vm/attach")) { - XCTAssertEqual($0 as? Switchboard.ConfigError, .plaintextToRemote("100.74.35.49")) + XCTAssertThrowsError(try Switchboard.validate("ws://100.64.0.7:8790/vm/attach")) { + XCTAssertEqual($0 as? Switchboard.ConfigError, .plaintextToRemote("100.64.0.7")) } } diff --git a/Tests/InstanceMCPCoreTests/TailnetExposureTests.swift b/Tests/InstanceMCPCoreTests/TailnetExposureTests.swift index 44a3284..3ae5efb 100644 --- a/Tests/InstanceMCPCoreTests/TailnetExposureTests.swift +++ b/Tests/InstanceMCPCoreTests/TailnetExposureTests.swift @@ -551,10 +551,10 @@ final class TailnetExposureTests: XCTestCase { func testTheReleaseBinaryDoesNotContainTheTestOverrideName() throws { let dir = Bundle(for: TailnetExposureTests.self).bundleURL.deletingLastPathComponent() // .../.build//debug/ -> .../release/ - let release = dir.deletingLastPathComponent().appendingPathComponent("release/oab-instance-mcp") + let release = dir.deletingLastPathComponent().appendingPathComponent("release/oab-computer") guard FileManager.default.fileExists(atPath: release.path) else { throw XCTSkip("no release binary built") } let data = try Data(contentsOf: release) - XCTAssertNil(data.range(of: Data("OAB_INSTANCE_MCP_NO_TAILNET_EXPOSURE".utf8))) + XCTAssertNil(data.range(of: Data("OAB_COMPUTER_NO_TAILNET_EXPOSURE".utf8))) } func testWithdrawReportsAMappingThatWillNotGoAway() { diff --git a/Tests/InstanceMCPCoreTests/TailscaleDetectionTests.swift b/Tests/InstanceMCPCoreTests/TailscaleDetectionTests.swift index 74ac983..6b3f0f5 100644 --- a/Tests/InstanceMCPCoreTests/TailscaleDetectionTests.swift +++ b/Tests/InstanceMCPCoreTests/TailscaleDetectionTests.swift @@ -4,8 +4,8 @@ import XCTest private let statusJSON = Data(""" {"BackendState":"Running","AuthURL":"https://login.tailscale.com/a/tskey-SECRET123", - "Self":{"DNSName":"mac.tailb836bb.ts.net.","UserID":8041768352297354,"HostName":"mac"}, - "User":{"8041768352297354":{"LoginName":"owner@example.com","DisplayName":"Owner"}}} + "Self":{"DNSName":"mac.tail1234.ts.net.","UserID":12345,"HostName":"mac"}, + "User":{"12345":{"LoginName":"owner@example.com","DisplayName":"Owner"}}} """.utf8) private final class TSClock: @unchecked Sendable { @@ -105,8 +105,8 @@ final class TailscaleDetectionTests: XCTestCase { } func testTheCLIGetsTERM_PROGRAMBecauseTheAppBinaryNeedsItOutsideATerminal() { - XCTAssertEqual(FirstRunSetup.tailscaleEnvironment([:])["TERM_PROGRAM"], "oab-instance-mcp") - XCTAssertEqual(FirstRunSetup.tailscaleEnvironment(["TERM_PROGRAM": ""])["TERM_PROGRAM"], "oab-instance-mcp") + XCTAssertEqual(FirstRunSetup.tailscaleEnvironment([:])["TERM_PROGRAM"], "oab-computer") + XCTAssertEqual(FirstRunSetup.tailscaleEnvironment(["TERM_PROGRAM": ""])["TERM_PROGRAM"], "oab-computer") XCTAssertEqual(FirstRunSetup.tailscaleEnvironment(["TERM_PROGRAM": "ghostty", "A": "b"]), ["TERM_PROGRAM": "ghostty", "A": "b"], "an existing value is kept") } diff --git a/Tests/InstanceMCPCoreTests/UISnapshotTests.swift b/Tests/InstanceMCPCoreTests/UISnapshotTests.swift index 65a5964..3f9e048 100644 --- a/Tests/InstanceMCPCoreTests/UISnapshotTests.swift +++ b/Tests/InstanceMCPCoreTests/UISnapshotTests.swift @@ -342,7 +342,7 @@ final class UISnapshotWalkerTests: XCTestCase { XCTAssertThrowsError(try walk([el("AXWindow", "w", a)])) { e in XCTAssertEqual((e as? ToolError)?.description, UISnapshotTool.Errors.notTrusted) } - XCTAssertTrue(UISnapshotTool.Errors.notTrusted.contains("dev.openab.instance-mcp")) + XCTAssertTrue(UISnapshotTool.Errors.notTrusted.contains("dev.openab.computer")) XCTAssertTrue(UISnapshotTool.Errors.notTrusted.contains("Privacy & Security → Accessibility")) } @@ -608,7 +608,7 @@ final class UISnapshotTargetTests: XCTestCase { UIRunningApp(pid: 30, bundleID: "a.helper", name: "Helper"), UIRunningApp(pid: 31, bundleID: "b.helper", name: "Helper"), UIRunningApp(pid: 40, bundleID: "com.apple.AirPlayUIAgent", name: "AirPlayUIAgent", regular: false), - UIRunningApp(pid: 99, bundleID: "dev.openab.instance-mcp", name: "oab-instance-mcp", regular: false), + UIRunningApp(pid: 99, bundleID: "dev.openab.computer", name: "oab-computer", regular: false), ] private func resolve(app: String? = nil, pid: Int32? = nil, front: Int32? = nil, @@ -632,7 +632,7 @@ final class UISnapshotTargetTests: XCTestCase { XCTAssertEqual(failure(resolve(app: "Nope")), "app not found: no running app has bundle id or name 'Nope'. Running apps: Finder, Helper, TextEdit") XCTAssertEqual(failure(resolve(pid: 99)), UISnapshotTool.Errors.selfApp) - XCTAssertEqual(failure(resolve(app: "dev.openab.instance-mcp")), UISnapshotTool.Errors.selfApp) + XCTAssertEqual(failure(resolve(app: "dev.openab.computer")), UISnapshotTool.Errors.selfApp) XCTAssertEqual(failure(resolve()), UISnapshotTool.Errors.noFrontmost) XCTAssertEqual(try resolve(front: 10).get().name, "Finder") XCTAssertEqual(try resolve(front: 55).get(), UIRunningApp(pid: 55, bundleID: nil, name: nil)) diff --git a/conformance/reverse_attach_vectors.json b/conformance/reverse_attach_vectors.json index e882fec..4f032b3 100644 --- a/conformance/reverse_attach_vectors.json +++ b/conformance/reverse_attach_vectors.json @@ -27,7 +27,7 @@ {"status": 400, "expect": {"stop_rejected": 400}} ], "attach_url": [ - {"runtime": "ws://100.111.174.31:8090", "session": "abc", "expect": "ws://100.111.174.31:8090/tools/attach/abc"}, + {"runtime": "ws://100.64.0.2:8090", "session": "abc", "expect": "ws://100.64.0.2:8090/tools/attach/abc"}, {"runtime": "wss://pod.tail.ts.net", "session": "s1", "expect": "wss://pod.tail.ts.net/tools/attach/s1"}, {"runtime": "ws://host:9/base/", "session": "x", "expect": "ws://host:9/base/tools/attach/x"} ] diff --git a/docs/adr/linux-rust-port.md b/docs/adr/linux-rust-port.md index 3bca2ed..e4aa2d6 100644 --- a/docs/adr/linux-rust-port.md +++ b/docs/adr/linux-rust-port.md @@ -1,5 +1,7 @@ # ADR: Linux-first Rust port — one core, per-OS backends +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). + - **Status:** Proposed - **Date:** 2026-09-27 - **Related:** [openabdev/instance-mcp#15](https://github.com/openabdev/instance-mcp/issues/15) (FR + phased plan + arch diagrams), [`reverse-attach.md`](reverse-attach.md), [`sandbox-adapter.md`](sandbox-adapter.md) @@ -28,7 +30,7 @@ That is the *higher-risk* majority of the work. The genuinely de-riskable part ( grim/ydotool/proc") is the ~640-line platform-specific *minority*. Effort and risk must be sized against re-implementing and re-securing the core, not against the small platform shim. -Target environment verified on `rpi1` (Raspberry Pi 5): aarch64, Debian 13 (trixie), labwc +Target environment verified on a Raspberry Pi (Raspberry Pi 5): aarch64, Debian 13 (trixie), labwc (Wayland) + lightdm, `grim`/`wlr-randr`/`scrot` installed, `/dev/uinput` present (root-only), Swift not installed. **Caveat: this is one hand-configured node.** It does not establish that cheap headless Linux VMs (often no compositor, no `WAYLAND_DISPLAY`, no seat) can run the @@ -121,6 +123,6 @@ only after a reimplementation reaches parity is strangler-fig migration. - Headless fleet — **decided (2026-09-28, #23): virtual compositor supported.** `install-linux.sh --headless-seat` runs sway with the headless wlroots backend as a user unit (one 1920×1080 output, pixman, no GPU/monitor) — the provisioning story; verified on a fresh - Ubuntu 24.04 server with no desktop (`black`, #34). Boxes that already run a wlroots desktop + Ubuntu 24.04 server with no desktop (a headless server, #34). Boxes that already run a wlroots desktop (Raspberry Pi OS) need nothing extra. - macOS release: cross-compile feasibility + CI runners for signing/notarization from Rust. diff --git a/docs/adr/reverse-attach.md b/docs/adr/reverse-attach.md index ce04360..e310caf 100644 --- a/docs/adr/reverse-attach.md +++ b/docs/adr/reverse-attach.md @@ -1,4 +1,6 @@ -# ADR: Reverse Attach — macmini dials the sandbox +# ADR: Reverse Attach — the Mac dials the sandbox + +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). - **Status:** Proposed - **Date:** 2026-09-25 @@ -129,7 +131,7 @@ sits on the Mac. keeps redialling for the remainder of the grant TTL. - **Assumption that must hold in deployment: the k8s node is not itself a tailnet member.** If it is, pods reach the whole tailnet through the node's tailscale routing regardless of the - sidecar (measured on p1, 2026-09-26; see the requirement doc's 4a results). Document it in + sidecar (measured on the k3s node, 2026-09-26; see the requirement doc's 4a results). Document it in the openab-pty k8s how-to; opt-in egress `NetworkPolicy` if the node must run tailscaled. - Residual, by design: a compromised agent can call the tools it *was* granted. Every design that gives an agent hands carries this; here the hands are exactly as large, and last exactly diff --git a/docs/adr/sandbox-adapter.md b/docs/adr/sandbox-adapter.md index 09da30b..934d210 100644 --- a/docs/adr/sandbox-adapter.md +++ b/docs/adr/sandbox-adapter.md @@ -1,5 +1,7 @@ # ADR: Sandbox Adapter +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). + - **Status:** Proposed - **Date:** 2026-09-24 - **Issue:** [#1](https://github.com/openabdev/instance-mcp/issues/1) diff --git a/docs/linux-setup.md b/docs/linux-setup.md index d6016b8..0ee964b 100644 --- a/docs/linux-setup.md +++ b/docs/linux-setup.md @@ -1,12 +1,12 @@ # Linux hands node setup -How to turn a Linux desktop box into an `oab-instance-mcp` hands node with the Rust PoC in +How to turn a Linux desktop box into an `oab-computer` hands node with the Rust PoC in [`poc/reverse-attach-linux/`](../poc/reverse-attach-linux/). Written from two deployments, both verified end to end on 2026-09-28 (lending to an openab-pty session, the OpenAB Connect Screens pane, browser tools): -- `rpi1` — Raspberry Pi OS / Debian 13, aarch64, the stock labwc desktop (a display exists). -- `black` — Ubuntu 24.04 Server, Intel N95, **no desktop at all**: a headless sway seat is created +- Pi node: Raspberry Pi OS / Debian 13, aarch64, the stock labwc desktop (a display exists). +- Headless server: Ubuntu 24.04 Server, Intel N95, **no desktop at all**: a headless sway seat is created by the installer (`--headless-seat`). Read the trust warning at the top of the [README](../README.md) first. On Linux it is stricter, @@ -29,8 +29,8 @@ flowchart LR subgraph node["Linux hands node — one box"] ts["tailscale serve :8444
TLS + injects Tailscale-User-Login"] subgraph seat["Desktop seat · systemd --user · WAYLAND_DISPLAY=wayland-0"] - mcp["oab-instance-mcp.service
reverse-attach binary · 127.0.0.1:8795
auth = allow-login AND bearer
/mcp · /attach · /healthz
sys_info · screenshot · bash · mouse · key
exec_start · exec_poll · exec_list · exec_cancel"] - pw["oab-pw-mcp.service
@playwright/mcp · 127.0.0.1:8794
headed distro Chromium, persistent profile"] + mcp["oab-computer.service
reverse-attach binary · 127.0.0.1:8795
auth = allow-login AND bearer
/mcp · /attach · /healthz
sys_info · screenshot · bash · mouse · key
exec_start · exec_poll · exec_list · exec_cancel"] + pw["oab-computer-pw-mcp.service
@playwright/mcp · 127.0.0.1:8794
headed distro Chromium, persistent profile"] end subgraph hands["What the tools touch"] grim["grim → screenshot"] @@ -60,7 +60,7 @@ terminates TLS and stamps the caller's Tailscale identity. Same shape as the Mac [Raspberry Pi notes](#raspberry-pi-notes) for the no-monitor case; - **none** — pass `--headless-seat` to the installer and it runs `sway` with the headless wlroots backend as user unit `oab-seat.service`: one 1920×1080 virtual output, no input - devices, no GPU or monitor needed (verified on `black`, Ubuntu 24.04 Server). + devices, no GPU or monitor needed (verified on a headless server, Ubuntu 24.04 Server). - The node on the tailnet with **MagicDNS + HTTPS certificates** enabled (needed for `tailscale serve`). Do **not** do this on a k3s node that hosts openab-pty pods: host-level `tailscaled` gives every pod tailnet egress as the node (measured, see @@ -93,17 +93,17 @@ with no URL at all, check `curl -4 -m 8 https://controlplane.tailscale.com/` — ## 2. Get the daemon -**Prebuilt (preferred).** Every `v*` release ships `oab-instance-mcp-VERSION-linux-arm64.tar.gz` +**Prebuilt (preferred).** Every `v*` release ships `oab-computer-VERSION-linux-arm64.tar.gz` and `-linux-amd64.tar.gz` (built and smoke-tested in CI on native runners) beside the macOS installer, with a `.sha256` each: ```sh -V=$(curl -fsSL https://api.github.com/repos/openabdev/instance-mcp/releases/latest | python3 -c 'import sys,json;print(json.load(sys.stdin)["tag_name"][1:])') +V=0.12.0 # the first release with oab-computer-* assets (v0.11.0 and older use the old names) A=$(dpkg --print-architecture) # arm64 or amd64 -curl -fsSLO "https://github.com/openabdev/instance-mcp/releases/download/v$V/oab-instance-mcp-$V-linux-$A.tar.gz" -curl -fsSLO "https://github.com/openabdev/instance-mcp/releases/download/v$V/oab-instance-mcp-$V-linux-$A.tar.gz.sha256" -sha256sum -c "oab-instance-mcp-$V-linux-$A.tar.gz.sha256" -tar xzf "oab-instance-mcp-$V-linux-$A.tar.gz" && cd "oab-instance-mcp-$V-linux-$A" +curl -fsSLO "https://github.com/openabdev/openab-computer/releases/download/v$V/oab-computer-$V-linux-$A.tar.gz" +curl -fsSLO "https://github.com/openabdev/openab-computer/releases/download/v$V/oab-computer-$V-linux-$A.tar.gz.sha256" +sha256sum -c "oab-computer-$V-linux-$A.tar.gz.sha256" +tar xzf "oab-computer-$V-linux-$A.tar.gz" && cd "oab-computer-$V-linux-$A" ./install-linux.sh # does steps 3–5 below; --allow-login / --no-browser / --port ./install-linux.sh --headless-seat # same, on a box with no desktop (creates oab-seat.service) ``` @@ -116,8 +116,8 @@ steps 3–5 describe what it did. **From source** (needs Rust ≥ 1.85): ```sh -git clone https://github.com/openabdev/instance-mcp ~/repo/instance-mcp -cd ~/repo/instance-mcp/poc/reverse-attach-linux +git clone https://github.com/openabdev/openab-computer ~/repo/oab-computer +cd ~/repo/oab-computer/poc/reverse-attach-linux cargo build --release # ~30 s cold on a Pi 4/5; 2.2 MB binary bash smoke.sh # 55 checks against the bundled mock runtime; all must pass ``` @@ -129,23 +129,23 @@ smoke runs in CI on amd64 and arm64 for every push. ## 3. Bearer token and the daemon unit ```sh -mkdir -p ~/.config/oab-instance-mcp -head -c 32 /dev/urandom | base64 | tr -d '=+/\n' | head -c 40 > ~/.config/oab-instance-mcp/token -chmod 600 ~/.config/oab-instance-mcp/token +mkdir -p ~/.config/oab-computer +head -c 32 /dev/urandom | base64 | tr -d '=+/\n' | head -c 40 > ~/.config/oab-computer/token +chmod 600 ~/.config/oab-computer/token LOGIN=$(sudo tailscale status --json | python3 -c \ 'import sys,json;d=json.load(sys.stdin);print(d["User"][str(d["Self"]["UserID"])]["LoginName"])') mkdir -p ~/.config/systemd/user -cat > ~/.config/systemd/user/oab-instance-mcp.service < ~/.config/systemd/user/oab-computer.service < ~/.config/systemd/user/oab-pw-mcp.service <<'EOF' +cat > ~/.config/systemd/user/oab-computer-pw-mcp.service <<'EOF' [Unit] -Description=Playwright MCP for the oab-instance-mcp hands node (loopback :8794) +Description=Playwright MCP for the oab-computer hands node (loopback :8794) After=graphical-session.target [Service] -ExecStart=%h/.local/oab-instance-mcp/pw-mcp.sh +ExecStart=%h/.local/oab-computer/pw-mcp.sh Environment=XDG_RUNTIME_DIR=/run/user/%U Environment=WAYLAND_DISPLAY=wayland-0 Environment=DISPLAY=:0 @@ -199,19 +199,19 @@ RestartSec=3 WantedBy=default.target EOF systemctl --user daemon-reload -systemctl --user enable --now oab-pw-mcp.service +systemctl --user enable --now oab-computer-pw-mcp.service # Block camera/mic/notification/geolocation prompts: YouTube raised an xdg-desktop-portal # "Allow app to use the Camera?" dialog that nothing but `mouse` could dismiss. sudo mkdir -p /etc/chromium/policies/managed echo '{"DefaultMediaStreamSetting": 2, "DefaultNotificationsSetting": 2, "DefaultGeolocationSetting": 2}' \ - | sudo tee /etc/chromium/policies/managed/oab-instance-mcp.json >/dev/null -systemctl --user restart oab-pw-mcp + | sudo tee /etc/chromium/policies/managed/oab-computer.json >/dev/null +systemctl --user restart oab-computer-pw-mcp ``` `pw-mcp.sh` uses the distro Chromium (`--executable-path /usr/bin/chromium`) so nothing is downloaded, launches it **headed** into the seat, keeps a persistent profile under -`~/.local/oab-instance-mcp/pw-profile`, and lists `--allowed-hosts` in both `host` and +`~/.local/oab-computer/pw-profile`, and lists `--allowed-hosts` in both `host` and `host:port` forms (Playwright answers 403 to everything otherwise). Chromium runs with its sandbox on; check with `tr '\0' '\n' cat ~/.config/oab-instance-mcp/token) +TOKEN=$(ssh cat ~/.config/oab-computer/token) U=https://..ts.net:8444 curl -s $U/healthz # ok curl -s -o /dev/null -w '%{http_code}\n' -X POST $U/mcp -H 'Authorization: Bearer nope' -d '{}' # 401 @@ -291,22 +291,22 @@ exec_cancel` + 32 `browser_*` + the runtime's `instance_status`. ## Operate ```sh -systemctl --user status oab-instance-mcp oab-pw-mcp -journalctl --user -u oab-instance-mcp -f # one line per deny / upstream error -systemctl --user restart oab-instance-mcp # grants are in memory: re-lend afterwards +systemctl --user status oab-computer oab-computer-pw-mcp +journalctl --user -u oab-computer -f # one line per deny / upstream error +systemctl --user restart oab-computer # grants are in memory: re-lend afterwards sudo tailscale serve --https=8444 off # stop exposing WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/$(id -u) wlr-randr # outputs the tools see ``` Grants survive a daemon restart (#12): live grants are kept in -`~/.local/state/oab-instance-mcp/grants.json` (mode 600; override with `MCP_GRANTS_FILE`, or +`~/.local/state/oab-computer/grants.json` (mode 600; override with `MCP_GRANTS_FILE`, or `MCP_GRANTS_FILE=off` to disable), and on start every grant still inside its deadline is re-dialled -under its original id — `journalctl --user -u oab-instance-mcp` shows `grants: resuming `. The +under its original id — `journalctl --user -u oab-computer` shows `grants: resuming `. The file holds the attach secrets, at the same trust level as the bearer token file. A grant whose runtime has forgotten it (pod replaced) ends with `handshake_rejected_401`; lend again. A request or stored grant that names the retired `desktop` profile runs as `observe` (#59). Each -such mapping is logged and appended to `~/.local/state/oab-instance-mcp/audit.jsonl` (mode 600; +such mapping is logged and appended to `~/.local/state/oab-computer/audit.jsonl` (mode 600; override with `MCP_AUDIT_FILE`, or `MCP_AUDIT_FILE=off` to disable). See [Upgrading from 0.8.x](tool-profiles.md#upgrading-from-08x-59). @@ -319,7 +319,7 @@ long call: same arguments and `structuredContent` as on macOS (README Tools tabl point the agent here. Under `observe` neither `bash` nor the job tools exist, and a long `bash` call is an *unknown tool*, not a refusal that names it. -- **Logs**: `$XDG_STATE_HOME/oab-instance-mcp/jobs/.out` / `.err` (default +- **Logs**: `$XDG_STATE_HOME/oab-computer/jobs/.out` / `.err` (default `~/.local/state/…`; directory mode 700, files 600). The job writes to them directly, so they are never truncated and can be followed with `tail -f`. `exec_poll` reads them by byte offset (`stdout_since` / `stderr_since` → `stdout_next` / `stderr_next`), at most 1 MiB per stream per @@ -342,11 +342,11 @@ call is an *unknown tool*, not a refusal that names it. whatever their age. GC runs at start-up and on each `exec_start`. ```sh -ls -lt ~/.local/state/oab-instance-mcp/jobs/ # .out / .err -tail -f ~/.local/state/oab-instance-mcp/jobs/.out +ls -lt ~/.local/state/oab-computer/jobs/ # .out / .err +tail -f ~/.local/state/oab-computer/jobs/.out ``` -## Headless server notes (Ubuntu 24.04 on `black`) +## Headless server notes (Ubuntu 24.04) What a box with no desktop needed beyond the Pi, all handled by `install-linux.sh --headless-seat`: @@ -367,7 +367,7 @@ What a box with no desktop needed beyond the Pi, all handled by `install-linux.s `locale: en-US` / `LANG=en_US.UTF-8`. - **Node from fnm/nvm**: `/usr/bin/node` was a symlink into `~/.local/share/fnm/...` with no `npm` on the system PATH; `PW_NODE_BIN` in the unit carries that directory. -- **Pod-host rule still applies**: `black` runs k3s (ARC controller, traefik) *and* host +- **Pod-host rule still applies**: The headless server runs k3s (ARC controller, traefik) *and* host `tailscaled`. That is fine only because it hosts no openab-pty pods; it must never start to. ## Raspberry Pi notes @@ -398,7 +398,7 @@ What a box with no desktop needed beyond the Pi, all handled by `install-linux.s `/run/user/` when unset, the units set them explicitly, and `pw-mcp.sh` adds `DISPLAY`. - **`grim -t jpeg` → "jpeg support disabled"** on Debian's build. The daemon never asks grim for JPEG: it takes raw PPM from grim and encodes the JPEG itself. -- **A seat with no physical input silently drops transient virtual devices.** Headless sway (black) +- **A seat with no physical input silently drops transient virtual devices.** Headless sway and a Pi with no mouse advertise no pointer/keyboard capability, so a `wlrctl`/`wtype` call that creates a device, sends and exits within milliseconds is lost: Chromium never binds `wl_pointer`/`wl_keyboard` in time, yet the tool exits 0 and `mouse`/`key` report `ok: true`. @@ -417,8 +417,8 @@ What a box with no desktop needed beyond the Pi, all handled by `install-linux.s use the `tailscale serve` URL even from the node itself. - **`pkill -f` inside `ssh host 'sh -c …'` kills the `sh -c`** whose command line matches. Use `pkill -x ` — but `-x` matches at most 15 characters of the process name, so - `pkill -x oab-instance-mcp` silently matches nothing. + `pkill -x oab-computer` silently matches nothing. - **Chromium `No usable sandbox!` / `Trace/breakpoint trap`** on Ubuntu 24.04+: AppArmor - userns restriction; see [Headless server notes](#headless-server-notes-ubuntu-2404-on-black). + userns restriction; see [Headless server notes](#headless-server-notes-ubuntu-2404). - **Headed browser "without having a XServer"** on a headless seat: Chromium picked X11; add `--ozone-platform=wayland` (the installer's `pw-config.json` does). diff --git a/docs/operators.md b/docs/operators.md index 713227d..ecfb0ab 100644 --- a/docs/operators.md +++ b/docs/operators.md @@ -41,7 +41,7 @@ written as placeholders or as "an M4 Mac mini". - **A LaunchAgent cannot read external volumes without Full Disk Access.** A `cwd` on `/Volumes/…` (e.g. a RAID where builds live) does not fail with EPERM — the syscall *blocks*, so an un-guarded `exec` would hang to its timeout. Two fixes: `ExecCwd` time-boxes - a readability probe and returns a clear "grant Full Disk Access to dev.openab.instance-mcp" + a readability probe and returns a clear "grant Full Disk Access to dev.openab.computer" error instead of hanging; and granting FDA to the bundle in System Settings → Privacy & Security → Full Disk Access makes the volume readable (verified 2026-09-23: same `ls /Volumes/…` went from a 15 s timeout to 39 ms). The grant sticks across re-signing like the diff --git a/docs/oracle-cloud-refarch.md b/docs/oracle-cloud-refarch.md index 08c7775..ab1764b 100644 --- a/docs/oracle-cloud-refarch.md +++ b/docs/oracle-cloud-refarch.md @@ -1,5 +1,15 @@ # Reference architecture: instance-mcp on an Oracle Cloud Ubuntu VM +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). + +| Old (0.8–0.11) | New (0.12.0) | +|---|---| +| `oab-instance-mcp` (binary, app, directories, unit, tarball) | `oab-computer` | +| `dev.openab.instance-mcp` (bundle id, LaunchAgent) | `dev.openab.computer` | +| `dev.openab.instance-mcp.pw-mcp`, `oab-pw-mcp.service` | `dev.openab.computer.pw-mcp`, `oab-computer-pw-mcp.service` | +| `oab-instance-mcp.service` | `oab-computer.service` | +| `OAB_INSTANCE_MCP_*` | `OAB_COMPUTER_*` | + > Agent-friendly prompt block (copy-paste to set up a new VM) > > ``` @@ -9,7 +19,7 @@ > 127.0.0.1:8795, behind `tailscale serve --https=8444`. Never print or paste the bearer token. > ``` -**Status.** Written from the live VM `oracle-vm`, checked with read-only commands and one +**Status.** Written from the live VM ``, checked with read-only commands and one handshake over the tailnet. Where the VM differs from the earlier investigation, this page describes the VM. Section 9 lists the differences. @@ -34,7 +44,7 @@ node by URL and bearer token. Connect then lends the node to a live openab-pty s dials back into that session's runtime, and the agent in the session calls the lent tools. ``` - OpenAB Connect (Mac) tailnet Oracle A1 VM (oracle-vm) + OpenAB Connect (Mac) tailnet Oracle A1 VM () ──────────────────── ───────────────────────────────────────────── computer entry ── HTTPS /mcp ──► tailscale serve :8444 ──► 127.0.0.1:8795 oab-instance-mcp (user oabmcp) lend sheet ────── POST /attach ─► │ /mcp /attach /healthz @@ -83,7 +93,7 @@ Use an Ubuntu 24.04 image. The v0.8.0 arm64 binary needs glibc 2.39, which Ubunt ### 2.2 Tailscale 1. Install Tailscale (see `docs/linux-setup.md`, section 1). The reference VM runs `tailscale` 1.104.4. -2. Join the tailnet with a hostname you will recognize. The reference node is `oracle-vm`. +2. Join the tailnet with a hostname you will recognize. The reference node is ``. 3. In the admin console, enable **MagicDNS** and **HTTPS Certificates**. Without HTTPS certificates, `tailscale serve --https` fails. 4. Prerequisite, not done: tag the node in the admin console before you rely on `owner` over the @@ -537,7 +547,7 @@ issues or email. | Field | Value | |---|---| -| Name | `oracle-vm` (any label works) | +| Name | `` (any label works) | | URL | `https://..ts.net:8444/mcp` | | Token | The content of ``. Paste it, then clear the clipboard. | @@ -548,7 +558,7 @@ computer**. | Field | Choice | |---|---| -| Computer | `oracle-vm` | +| Computer | `` | | Profile | `observe` for any session that reads untrusted content. Use `owner` only for trusted tasks (section 5). | | Lease | 1 to 24 hours | @@ -571,7 +581,7 @@ you chose. For `observe`, that is `sys_info` and `screenshot` only. 1. Generate a new value in the same file with the command in 3.7. The file is replaced in place. 2. Restart the daemon (7.3). The daemon reads the token file at start. -3. In Connect, edit the `oracle-vm` computer and paste the new token. +3. In Connect, edit the `` computer and paste the new token. Connect cannot reach the node until its stored token matches the file again. Rotation was not run on the reference VM. These steps follow the installer's generation command and the daemon's start-time diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 63e31b0..6af1951 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -119,7 +119,7 @@ local tool is served under `owner` only, except `sys_info` and `screenshot`, whi | `osascript` | `OsascriptTool` | no (macOS only) | `shell`; `owner` only | none | | `ui_snapshot` | `UISnapshotTool` (`UISnapshot/`, AXUIElement) | no (Linux AT-SPI deferred, #102) | `observe`-class on macOS; `owner` only (not in `observe` in phase 1) | `profile_vectors.json` `macos` lists; Swift `UISnapshotWalkerTests`/`UISnapshotTargetTests`/`UISnapshotToolTests` (fake AX provider, no GUI); Rust `profile_conformance::macos_only_tools_are_not_served_on_linux` | | `ui_action` | `UIActionTool` (`UISnapshot/UIAction.swift`, `UISnapshotCache.swift`; AX actions, no pointer movement) | no (Linux AT-SPI deferred, #102) | `shell` on macOS; `owner` only | `profile_vectors.json` `macos` lists; Swift `UIActionToolTests`/`UISnapshotCacheTests` (fake AX provider, no GUI), `ProfileBoundaryTests.testUIActionIsOwnerOnlyShellClass`; Rust `profile_conformance::macos_only_tools_are_not_served_on_linux` | -| `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | `AsyncExecTools`, `JobRegistry` | yes (`src/jobs/`, #32 item 4): same names, arguments and `structuredContent`; `bash -c` instead of `zsh -f -c`; logs under `$XDG_STATE_HOME/oab-instance-mcp/jobs/`; `shell`-class, `owner` only (Swift classes `exec_start` as `shell`, `exec_poll`/`exec_list` as `observe` and `exec_cancel` as `act`; both expose the four to `owner` only, so the gating is the same) | Swift: `exec_start` `shell`, `exec_poll`/`exec_list` `observe`, `exec_cancel` `act`; Rust: all `shell`. `owner` only on both | Rust unit tests `jobs::tests` and `smoke.sh` (start, offset polls, survives an attach drop, cancel → `killed`); no shared vectors. Differences: Rust keeps the 10 most recent finished jobs (Swift: finished metadata for ~10 min), pages `exec_poll` at 1 MiB per stream, and refuses a `signal` other than `KILL`/`TERM`. A job stays `running` until its shell has exited and its process group is empty (Swift: until its output pipes close), so a child the shell left behind can still be cancelled or timed out | +| `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | `AsyncExecTools`, `JobRegistry` | yes (`src/jobs/`, #32 item 4): same names, arguments and `structuredContent`; `bash -c` instead of `zsh -f -c`; logs under `$XDG_STATE_HOME/oab-computer/jobs/`; `shell`-class, `owner` only (Swift classes `exec_start` as `shell`, `exec_poll`/`exec_list` as `observe` and `exec_cancel` as `act`; both expose the four to `owner` only, so the gating is the same) | Swift: `exec_start` `shell`, `exec_poll`/`exec_list` `observe`, `exec_cancel` `act`; Rust: all `shell`. `owner` only on both | Rust unit tests `jobs::tests` and `smoke.sh` (start, offset polls, survives an attach drop, cancel → `killed`); no shared vectors. Differences: Rust keeps the 10 most recent finished jobs (Swift: finished metadata for ~10 min), pages `exec_poll` at 1 MiB per stream, and refuses a `signal` other than `KILL`/`TERM`. A job stays `running` until its shell has exited and its process group is empty (Swift: until its output pipes close), so a child the shell left behind can still be cancelled or timed out | | `browser_*` (upstream Playwright) | `UpstreamMCP` | yes (`mcp.rs` `Upstream`, `MCP_UPSTREAM`) | not classified locally; every upstream tool is `owner` only on both, `observe` gets none (#59) | `profile_vectors.json` membership on both; the full 32-tool fixture through a fake upstream: `BrowserProfileTests` (Swift), `profile_conformance::owner_reaches_every_upstream_tool_and_observe_none` (Rust) | ## 6. Profile rows diff --git a/docs/releasing.md b/docs/releasing.md index 52a9f1e..18aabcd 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,4 +1,4 @@ -# Releasing oab-instance-mcp for macOS +# Releasing oab-computer for macOS A release is a **universal (arm64 + x86_64), Developer-ID-signed and Apple-notarized** app plus a signed/notarized installer package. The package is the normal download; the app zip is for advanced @@ -10,21 +10,26 @@ A `vMAJOR.MINOR.PATCH` tag publishes a GitHub Release titled `oab-computer VERSI | Artifact | Use | |---|---| -| `oab-instance-mcp-VERSION-universal.pkg` | Recommended. Double-click; installs/configures the app for the logged-in desktop user | -| `oab-instance-mcp-VERSION-universal.app.zip` | Pre-signed app, no package receipt. Contains `Contents/Resources/install-prebuilt.sh` for manual installation | +| `oab-computer-VERSION-universal.pkg` | Recommended. Double-click; installs/configures the app for the logged-in desktop user | +| `oab-computer-VERSION-universal.app.zip` | Pre-signed app, no package receipt. Contains `Contents/Resources/install-prebuilt.sh` for manual installation | | `SHA256SUMS` | SHA-256 of both artifacts | -Only the release title uses the new name: asset names (`oab-instance-mcp-VERSION-*`), the binary, -the bundle id and the install paths stay `oab-instance-mcp` on purpose (see #63). +Since 0.12.0 the asset names (`oab-computer-VERSION-*`), the binary, the bundle id and the install +paths all use `oab-computer` (up to 0.11 they were `oab-instance-mcp`). The installer: 1. verifies bundle id, code signature and team `6LPQNY95AQ` **before** stopping the running service; 2. detects the current Tailscale login and MagicDNS name from structured `tailscale status --json`; -3. installs to `~/.local/oab-instance-mcp/oab-instance-mcp.app` without re-signing it; -4. creates a bearer token once at `~/.config/oab-computer/token`, mode 600, and preserves it on updates (the first 0.12 install copies a 0.11 token from `~/.config/oab-instance-mcp/token` once; Linux keeps `~/.config/oab-instance-mcp`); -5. writes/starts the Aqua-user LaunchAgent `dev.openab.instance-mcp`; -6. adds the Playwright upstream when `dev.openab.instance-mcp.pw-mcp` is installed; and +3. installs to `~/.local/oab-computer/oab-computer.app` without re-signing it; +4. creates a bearer token once at `~/.config/oab-computer/token`, mode 600, and preserves it on updates. A 0.11 token at + `~/.config/oab-instance-mcp/token` is copied (cp, never mv; the old file stays in place) by these rules: + - macOS: only when this run creates the directory `~/.config/oab-computer`. If that directory already exists nothing is copied, + and a new token is generated only if the new token file does not exist. `FirstRunSetup.swift` applies the same one-shot rule. + - Linux: whenever `~/.config/oab-computer/token` is missing; a token is generated only when neither file has one. + - Either platform: a user who never had an old token gets a new one and must update clients. +5. writes/starts the Aqua-user LaunchAgent `dev.openab.computer`; +6. adds the Playwright upstream when `dev.openab.computer.pw-mcp` is installed; and 7. configures `tailscale serve --https=8444` to loopback port 8795. A logged-in GUI user and a logged-in Tailscale app are prerequisites. Package scripts run as root, @@ -34,8 +39,8 @@ LaunchAgent or config is written to root's home. ## One-time TCC grant After the first Developer-ID release install, enable **OpenAB Computer** once under System Settings → -Privacy & Security (older docs and builds before 0.9.0 list it as **oab-instance-mcp**; the bundle id -`dev.openab.instance-mcp` is unchanged, so it is the same entry): +Privacy & Security (builds before 0.12.0 used the bundle id `dev.openab.instance-mcp`, so their +entries do not carry over and must be granted again): - Full Disk Access - Screen & System Audio Recording @@ -76,7 +81,7 @@ deleted in an `always()` cleanup step. ## Cut a release -1. Update `let version = "…"` in `Sources/oab-instance-mcp/main.swift` and merge with green CI. +1. Update `let version = "…"` in `Sources/oab-computer/main.swift` and merge with green CI. This is the only version to bump. The Linux binary takes its version from the tag at build time (see [Linux version](#linux-version)); do not bump `poc/reverse-attach-linux/Cargo.toml`. 2. Ensure the matching active-team release secrets above exist. @@ -88,22 +93,22 @@ deleted in an `always()` cleanup step. ``` 4. The `Release macOS installer` workflow builds/tests, signs, notarizes and publishes the macOS - artifacts, and (since 0.6.7) `oab-instance-mcp-VERSION-linux-{arm64,amd64}.tar.gz` + `.sha256` + artifacts, and (since 0.6.7, named `oab-instance-mcp-VERSION-linux-*` before 0.12.0) `oab-computer-VERSION-linux-{arm64,amd64}.tar.gz` + `.sha256` from the same run. All three jobs wait on the `release` environment approval. A manual dispatch can retry an existing tag; it is not a way to release an untagged commit, and it **must run on the tag ref** — the environment only allows `v*` refs, so a dispatch from `main` is rejected before any job starts ("Branch main is not allowed to deploy to release"): ```sh - gh workflow run release.yml --ref v0.8.4 -f tag=v0.8.4 + gh workflow run release.yml --ref v0.12.0 -f tag=v0.12.0 ``` 5. Download both artifacts and verify before installing: ```sh scripts/verify-release.sh \ - oab-instance-mcp-0.8.4-universal.app.zip \ - oab-instance-mcp-0.8.4-universal.pkg + oab-computer-0.12.0-universal.app.zip \ + oab-computer-0.12.0-universal.pkg shasum -a 256 -c SHA256SUMS ``` @@ -125,7 +130,7 @@ The Linux hands node reports the release version in `sys_info.agent.version` and `serverInfo.version` (#74). It is not read from `poc/reverse-attach-linux/Cargo.toml`: - The release workflow's Linux job checks that the tag equals `let version` in - `Sources/oab-instance-mcp/main.swift`, the same gate as the macOS job, then builds with + `Sources/oab-computer/main.swift`, the same gate as the macOS job, then builds with `OAB_RELEASE_VERSION=`, which `option_env!` compiles into the binary. Both jobs and PR CI read the version with `scripts/release-version.sh [tag]`, which requires one `MAJOR.MINOR.PATCH` line and exact string equality with the tag. @@ -140,7 +145,7 @@ CI runs this on every PR: ```sh swift build -scripts/test-packaging.sh .build/debug/oab-instance-mcp +scripts/test-packaging.sh .build/debug/oab-computer ``` It assembles the app, proves unsigned input is rejected by the production installer, exercises the @@ -153,12 +158,12 @@ To build a universal unsigned artifact manually on a Mac: ```sh swift build -c release --arch arm64 --scratch-path /tmp/imcp-arm64 swift build -c release --arch x86_64 --scratch-path /tmp/imcp-x86_64 -lipo -create /tmp/imcp-arm64/release/oab-instance-mcp \ - /tmp/imcp-x86_64/release/oab-instance-mcp \ - -output /tmp/oab-instance-mcp -chmod +x /tmp/oab-instance-mcp -scripts/assemble-app.sh /tmp/oab-instance-mcp /tmp/oab-instance-mcp.app 0.8.4 -ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-instance-mcp.app /tmp/oab-instance-mcp.pkg 0.8.4 +lipo -create /tmp/imcp-arm64/release/oab-computer \ + /tmp/imcp-x86_64/release/oab-computer \ + -output /tmp/oab-computer +chmod +x /tmp/oab-computer +scripts/assemble-app.sh /tmp/oab-computer /tmp/oab-computer.app 0.12.0 +ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-computer.app /tmp/oab-computer.pkg 0.12.0 ``` Unsigned artifacts are testing inputs only; do not install or publish them. diff --git a/docs/requirements/authn.md b/docs/requirements/authn.md index 0c7d1e5..4a195cd 100644 --- a/docs/requirements/authn.md +++ b/docs/requirements/authn.md @@ -1,16 +1,18 @@ # Design — bearer-token authn as defence-in-depth for the MCP endpoint +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). + > Recorded 2026-09-23. Implements the "two callers, two trust levels, one daemon" > constraint from [`connect-closed-loop.md`](connect-closed-loop.md), first slice: add a > shared bearer token so a leaked tailnet credential alone cannot reach the agent. ## Problem -`oab-instance-mcp` runs on macmini bound to loopback; `tailscale serve --https=8444` terminates +`oab-instance-mcp` runs on the Mac bound to loopback; `tailscale serve --https=8444` terminates TLS and injects `Tailscale-User-Login`, and `AuthPolicy` checks it against `--allow-login `. Today that is the *only* control (no `--token` in the deployed LaunchAgent). OpenAB Connect's `MacAgentClient` connects straight to -`https://macmini..ts.net:8444/mcp` and sends **no** `Authorization` header — its own +`https://..ts.net:8444/mcp` and sends **no** `Authorization` header — its own comment states "nothing here is a secret — auth is the caller's Tailscale identity". ### Threat: a leaked tailnet credential is sufficient @@ -52,12 +54,12 @@ attributable in logs), unlike a tailnet key whose revocation needs the admin con additive, not a rewrite. - **mTLS / short-lived certs** — heavier than warranted for a single-operator mesh. - **SSH-tunnel-based auth** — rejected: the sandbox agent's connectivity model is tsnet on the - tailnet (per `connect-closed-loop.md`), not SSH, and giving a sandbox an SSH key to macmini + tailnet (per `connect-closed-loop.md`), not SSH, and giving a sandbox an SSH key to the Mac would grant *more* than the MCP endpoint, not less. ## Token lifecycle -**Generation & storage (agent side, macmini).** `deploy.sh` generates a random token on first +**Generation & storage (agent side, the Mac).** `deploy.sh` generates a random token on first deploy if one does not already exist, stores it at `~/.config/oab-instance-mcp/token` (mode 600), and passes `--token-file` to the LaunchAgent (alongside the existing `--allow-login`). Re-deploys reuse the existing file so the token is stable across upgrades (like the TCC grants). Rotation = @@ -101,7 +103,7 @@ profile. - Unit: `AuthPolicy` AND semantics already tested (token required even for an allow-listed login; wrong token denied). -- Live on macmini: with the agent deployed with `--token-file`, +- Live on the Mac: with the agent deployed with `--token-file`, - a request with correct login header but **no** `Authorization` → `401`; - the same request **with** `Authorization: Bearer ` → `200`; - Connect can watch the screen only after its profile's token is set. diff --git a/docs/requirements/connect-closed-loop.md b/docs/requirements/connect-closed-loop.md index b30b8ef..7f30d26 100644 --- a/docs/requirements/connect-closed-loop.md +++ b/docs/requirements/connect-closed-loop.md @@ -1,5 +1,7 @@ # Requirement — OpenAB Connect closed loop: agent operates the Mac mini, human observes +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). + > Recorded 2026-09-22 (mid-Step-1 PoC). Not scheduled; captured so Step 2/3 design > choices do not foreclose it. Parent design: openabdev/openab#1544. @@ -45,7 +47,7 @@ loop — Connect is where the human both talks to the agent and sees the consequ ## Reachability - Connect (human's Mac / iPhone) → Mac mini screen session: direct over the tailnet, - `wss://macmini..ts.net:/…`. Video stream must not transit the sandbox. + `wss://the Mac..ts.net:/…`. Video stream must not transit the sandbox. - Agent (inside the `openab-pty` sandbox, k8s/ECS) → Mac mini: needs the sandbox on the tailnet — `tsnet` embedded in the pod, Tailscale k8s operator, or `tailscaled` sidecar (issue §1). Locally today the "sandbox" is just kiro-cli on the laptop, which already @@ -61,7 +63,7 @@ loop — Connect is where the human both talks to the agent and sees the consequ | 2b | Capture pipeline split into shared component; H.264 stream endpoint over WSS with a `ffplay`/`mpv` smoke test | 2 | | 3 | Connect: new session kind "Mac screen", attach + render via `AVSampleBufferDisplayLayer`; read-only | 2b + openab-pty contract review | | 3b | Connect: mouse/keyboard passthrough (human intervention) | 3 | -| 4 | Connect's agent (in sandbox) reaches macmini MCP over tailnet — `tsnet` or sidecar in the `openab-pty` image | openab-pty change | +| 4 | Connect's agent (in sandbox) reaches the Mac MCP over tailnet — `tsnet` or sidecar in the `openab-pty` image | openab-pty change | ## Open questions diff --git a/docs/requirements/reverse-attach.md b/docs/requirements/reverse-attach.md index ee7802f..d44c403 100644 --- a/docs/requirements/reverse-attach.md +++ b/docs/requirements/reverse-attach.md @@ -1,21 +1,23 @@ -# Requirement — reverse attach: macmini dials the sandbox, the sandbox has no tailnet egress +# Requirement — reverse attach: the Mac dials the sandbox, the sandbox has no tailnet egress + +**Renamed** from `oab-instance-mcp` to `oab-computer` in 0.12.0 (the product name is OpenAB Computer). This is a dated record: paths, unit, label and artifact names below are the 0.8–0.11 names (`oab-instance-mcp`, `dev.openab.instance-mcp`, `oab-pw-mcp.service`). > Recorded 2026-09-24. Implements **Phase 4** of > [`connect-closed-loop.md`](connect-closed-loop.md): "Connect's agent (in the `openab-pty` -> sandbox) reaches macmini MCP over the tailnet." Supersedes the earlier *sandbox-egress* +> sandbox) reaches the Mac MCP over the tailnet." Supersedes the earlier *sandbox-egress* > draft of the same day, which is summarised under "Rejected" below. ## Statement A coding CLI (kiro-cli / claude / openab) runs inside the `openab-pty` sandbox — a k8s pod or -ECS task — and must be able to call `oab-instance-mcp` tools on macmini (screenshot / mouse / +ECS task — and must be able to call `oab-instance-mcp` tools on the Mac (screenshot / mouse / key / osascript / exec) so the agent can drive the Mac while the human watches in Connect. The sandbox shell has **no network identity of its own** (`uid 1000`, no host creds, read-only rootfs); the tailscale sidecar is the pod's only tailnet identity and it is **inbound-only** (tailnet → sidecar → loopback runtime). Every earlier idea started from "give the sandbox an egress path". This document starts from the opposite premise: **the sandbox gets no tailnet -egress — macmini connects *in*.** (Internet egress is a separate matter and is open by default.) +egress — the Mac connects *in*.** (Internet egress is a separate matter and is open by default.) ## Rejected — every egress-based variant shares one root flaw @@ -26,7 +28,7 @@ everything after that is patching: |---|---| | sidecar as SOCKS/HTTP proxy + `HTTPS_PROXY` in the shell | honoring `HTTPS_PROXY` is a per-client convention (Node `fetch`/undici ignores it; raw `hyper`/custom Go transports ignore it); MCP's Streamable HTTP + SSE is proxy-hostile | | agent holds the bearer token and connects itself | token lands in the shell (`uid 1000`, prompt-injectable) — breaks openab-pty's "the shell never holds a credential" invariant | -| runtime holds the token and reverse-proxies to macmini (the earlier draft) | keeps the token out of the shell, but a compromised agent can still bind its own loopback relay to the sidecar's egress and reach **any** tailnet node; needs destination lock-down + ACLs, and per-tool scoping would mean parsing MCP inside a proxy (fragile) | +| runtime holds the token and reverse-proxies to the Mac (the earlier draft) | keeps the token out of the shell, but a compromised agent can still bind its own loopback relay to the sidecar's egress and reach **any** tailnet node; needs destination lock-down + ACLs, and per-tool scoping would mean parsing MCP inside a proxy (fragile) | All three require changing the sidecar from inbound-only to inbound+egress. Removing egress removes the whole class of problems. @@ -42,8 +44,8 @@ needs less new code than reverse attach. It was not chosen for these reasons: |---|---|---| | How egress physically works | The sidecar is `--tun=userspace-networking` on both k8s and ECS (`deploy/`), so there is no tun device: egress exists only as a tailscaled SOCKS5/HTTP proxy that clients must *opt into*. Either the shell sets `HTTPS_PROXY` (per-client convention; token in the shell) or the runtime forwards loopback→SOCKS5 (variant 3 + ACL). Kernel mode would need `NET_ADMIN`+`/dev/net/tun`, which Fargate does not have | none; sidecar unchanged | | Granularity of trust | ACL is **per tag, per port**: every pod carrying the tag can reach every Mac carrying the other tag, forever, until the policy is edited (control-plane propagation). Per-session grant / TTL / revoke must be rebuilt as a server-side allowlist on instance-mcp | per session, TTL'd, revoked by deleting the verifier — native | -| Dependency on Tailscale policy correctness | the security boundary now includes the ACL file: the default `src:["*"], dst:["*:*"]` rule (present on most tailnets) silently defeats the tag rule; ACL is port-level, and macmini's `tailscale serve :443` multiplexes other services (e.g. the OTA server), so granting `:443` grants all of them unless instance-mcp moves to a dedicated port | no ACL dependency; the pod initiates nothing, so an over-permissive ACL changes nothing | -| Identity | tagged nodes carry no user login; instance-mcp could authenticate by `tailscale whois ` tag or an ACL `grants` app-capability (`openab.dev/cap/instance-mcp: [{profile}]`) — a clean pattern, but it moves tool-profile policy into the ACL file | macmini chooses the profile at dial time; nothing to look up | +| Dependency on Tailscale policy correctness | the security boundary now includes the ACL file: the default `src:["*"], dst:["*:*"]` rule (present on most tailnets) silently defeats the tag rule; ACL is port-level, and the Mac's `tailscale serve :443` multiplexes other services (e.g. the OTA server), so granting `:443` grants all of them unless instance-mcp moves to a dedicated port | no ACL dependency; the pod initiates nothing, so an over-permissive ACL changes nothing | +| Identity | tagged nodes carry no user login; instance-mcp could authenticate by `tailscale whois ` tag or an ACL `grants` app-capability (`openab.dev/cap/instance-mcp: [{profile}]`) — a clean pattern, but it moves tool-profile policy into the ACL file | The Mac chooses the profile at dial time; nothing to look up | | New code | runtime loopback→SOCKS5 forwarder; instance-mcp allowlist + whois; ACL change + audit; sidecar flag | runtime `WS /tools/attach` + loopback mux; instance-mcp WS client + per-connection tool list | If reverse attach ever proves unworkable, ACL-scoped egress is the fallback — **with** the @@ -54,7 +56,7 @@ on the instance-mcp side. ## Decision — reverse attach ``` - openab-pty pod macmini + openab-pty pod the Mac ┌──────────────────────────────────────────┐ ┌──────────────────────────────┐ │ agent CLI runtime │ │ oab-instance-mcp │ │ ┌──────────┐ ┌──────────────────┐ │ │ ┌────────────────────────┐ │ @@ -75,37 +77,37 @@ on the instance-mcp side. loopback listener (reached through the sidecar's existing `tailscale serve`, exactly like `/pty/{session}` today), credentialed the same way the admin plane is: the pod stores only a `sha256:` **verifier**, never a usable secret. -2. **macmini (`oab-instance-mcp`)** gains a **reverse-attach client**: it dials +2. **The Mac (`oab-instance-mcp`)** gains a **reverse-attach client**: it dials `wss://..ts.net/tools/attach`, authenticates with a credential *it* holds, and serves MCP over that socket. 3. The runtime exposes a **loopback MCP listener** (`http://127.0.0.1:/mcp`) for the agent and multiplexes requests over the reverse socket. The agent's `mcp.json` points at a plain local URL: no token, no TLS, no proxy, and its HTTP stack is irrelevant. -4. **Tool profile is chosen by macmini per attach.** macmini *is* the MCP server, so scoping +4. **Tool profile is chosen by the Mac per attach.** The Mac *is* the MCP server, so scoping is a per-connection tool list in `MCPServer` — `tools/list` returns the profile's tools and `tools/call` rejects anything else. No MCP parsing in a proxy. The `sandbox` profile excludes `exec`/`exec_start` (or gates them on Connect approval, below). -### Who tells macmini which pod to dial — the human, in Connect or Remote +### Who tells the Mac which pod to dial — the human, in Connect or Remote -Pods are ephemeral; macmini cannot be configured with them. Connect already lists both PTY +Pods are ephemeral; the Mac cannot be configured with them. Connect already lists both PTY sessions and Mac agents. The user selects a PTY session and chooses **"lend my Mac to this agent"** with a profile and a TTL; the client (human credential, over the tailnet) calls a new admin endpoint on `oab-instance-mcp` — `POST /attach {runtime, session, profile, ttl}` — and -macmini dials in. The grant is **explicit, per-session, time-bounded, revocable**, and made +The Mac dials in. The grant is **explicit, per-session, time-bounded, revocable**, and made in the same app where the human watches the screen. This completes the closed loop: the human watches *and* authorises from one place. **Both OpenAB Connect (Mac) and OpenAB Remote (iPhone) can issue the grant.** The grant is a single authenticated HTTP call; everything that follows (dialling the pod, holding the socket, -enforcing the TTL) happens on macmini. The granting device does not need to stay online — the +enforcing the TTL) happens on the Mac. The granting device does not need to stay online — the phone can be put away after the tap. "I allow you to use my Mac mini for one hour" is exactly this call from the phone. #### How a grant works ``` - Connect / Remote oab-instance-mcp (macmini) openab-pty pod + Connect / Remote oab-instance-mcp (the Mac) openab-pty pod ──────────────── ────────────────────────── ────────────── 1. pick PTY session, profile, TTL @@ -117,7 +119,7 @@ this call from the phone. grant {session, profile, expires} 4. hand the sha256 verifier to the pod's admin plane ────────────────────► store verifier for - (admin credential held by macmini) that session only + (admin credential held by the Mac) that session only 5. dial wss:///tools/attach ────────► verify, accept with the secret; serve MCP with the profile's tool list @@ -130,7 +132,7 @@ this call from the phone. Notes on the grant model: -- **Grant lives on macmini**, not on the granting device. Connect and Remote are equal +- **Grant lives on the Mac**, not on the granting device. Connect and Remote are equal clients of the same endpoint; a grant made from the phone can be revoked from the Mac and vice versa (`GET /attach` lists active grants). - **Attribution.** The grant record carries the human login that authorised it (from @@ -138,13 +140,13 @@ Notes on the grant model: made under it — "who lent the Mac to which session, for how long". - **Profile is part of the grant**, fixed for its lifetime. Widening (e.g. adding `exec`) is a new grant, not an edit, so the audit trail stays honest. -- **Renewal** is a new `POST /attach` for the same session before expiry; macmini rotates the +- **Renewal** is a new `POST /attach` for the same session before expiry; the Mac rotates the secret and keeps the socket. Nothing auto-renews. - **Mint path for the verifier (step 4)** — 4b implements **both**, exactly one per request: - `admin_credential` (macmini calls the pod's `POST /admin/sessions/{s}/tools-attach` with it + `admin_credential` (the Mac calls the pod's `POST /admin/sessions/{s}/tools-attach` with it **and forwards the requested `ttl_secs` in the JSON body**, uses the credential for that request only, keeps nothing; the runtime's returned expiry wins) or `secret` (the client - minted at the pod itself and hands macmini the result). Connect/Remote offer 1/2/4/12/24h; + minted at the pod itself and hands the Mac the result). Connect/Remote offer 1/2/4/12/24h; instance-mcp and the default runtime ceiling allow 24h. A lower runtime ceiling rejects with 400 rather than silently shortening the lease. The grant record stores neither secret. @@ -167,18 +169,18 @@ node over that socket. | OpenClaw | Reverse attach | |---|---| | gateway — WS server, the hub | openab-pty runtime `WS /tools/attach`, reached through the existing `tailscale serve` | -| node (macOS app) dials the gateway | `oab-instance-mcp` on macmini dials the pod | +| node (macOS app) dials the gateway | `oab-instance-mcp` on the Mac dials the pod | | device pairing — a human approves the node | admin plane mints an attach verifier = "lend my Mac to this session" in Connect | -| node advertises capabilities | macmini's `tools/list` per profile (`sandbox` omits `exec*`) | +| node advertises capabilities | the Mac's `tools/list` per profile (`sandbox` omits `exec*`) | | agent's `nodes` tool routes to the node | runtime's loopback `127.0.0.1:/mcp` muxes the CLI's MCP requests over the reverse socket | | node protocol is OpenClaw-specific | plain MCP JSON-RPC frames over WS; the mux needs only `id` | **Where the analogy inverts — and why "who dials whom" is the only hard part.** In OpenClaw the *hub* is long-lived with a fixed address and the *nodes* are ephemeral, so a node always -knows where to dial. Here the hub (the pod) is ephemeral and the node (macmini) is the -long-lived one. That is why macmini must be *told* which pod to dial (`POST /attach` from -Connect) and why retry/reconnect ownership sits on macmini, the dialer: when a pod is -recreated the verifier is gone with it, Connect re-mints, and macmini keeps redialling for the +knows where to dial. Here the hub (the pod) is ephemeral and the node (the Mac) is the +long-lived one. That is why the Mac must be *told* which pod to dial (`POST /attach` from +Connect) and why retry/reconnect ownership sits on the Mac, the dialer: when a pod is +recreated the verifier is gone with it, Connect re-mints, and the Mac keeps redialling for the remainder of the grant TTL. ### Other dial-out designs with the same reasoning @@ -199,8 +201,8 @@ remainder of the grant TTL. | compromised agent reaches other tailnet nodes | **no path**, provided the k8s node itself does not run `tailscaled` (otherwise apply openab-pty `networkpolicy-no-tailnet-egress.yaml`). The pod still reaches the internet by default | | compromised agent uses the Mac's full shell via `exec` | **not blocked by removing `exec`.** Superseded (#45): GUI control is a shell, so `desktop` (formerly `sandbox`) was shell-equivalent. Since openab-computer#59 there are two profiles, `owner` (shell-equivalent) and `observe` (the only boundary); `desktop` is removed and resolves to `observe`. See `docs/tool-profiles.md` | | compromised agent calls the tools it *was* granted | **residual, by design** — giving an agent hands carries this in every design; the difference is the hands are exactly as large as the human chose, for as long as they chose | -| macmini's attach credential leaks | attacker could serve a fake `/tools/attach`? No — the credential authenticates macmini *to the pod*; a leaked one lets an attacker impersonate macmini to that pod, not reach macmini. Mint per-attach, TTL'd, revoke by deleting the verifier | -| **the k8s node itself is a tailnet member** (homelab k3s boxes often are) | **out of scope, must be documented as an assumption** — pods ride the node's tailscale routing (`ip rule … lookup 52` → `tailscale0`, masqueraded as the node) and reach every tailnet peer regardless of the sidecar. Measured on p1 in 4a: with the host's tailscaled running the shell reached macmini/black over their tailnet IPs; with it stopped, no path at all. Deployment rule: **no tailscaled on the node**; if unavoidable, an egress `NetworkPolicy` denying `100.64.0.0/10` + `fd7a:115c:a1e0::/48`. Fargate has no such path | +| the Mac's attach credential leaks | attacker could serve a fake `/tools/attach`? No — the credential authenticates the Mac *to the pod*; a leaked one lets an attacker impersonate the Mac to that pod, not reach the Mac. Mint per-attach, TTL'd, revoke by deleting the verifier | +| **the k8s node itself is a tailnet member** (homelab k3s boxes often are) | **out of scope, must be documented as an assumption** — pods ride the node's tailscale routing (`ip rule … lookup 52` → `tailscale0`, masqueraded as the node) and reach every tailnet peer regardless of the sidecar. Measured on the k3s node in 4a: with the host's tailscaled running the shell reached the Mac and other tailnet hosts over their tailnet IPs; with it stopped, no path at all. Deployment rule: **no tailscaled on the node**; if unavoidable, an egress `NetworkPolicy` denying `100.64.0.0/10` + `fd7a:115c:a1e0::/48`. Fargate has no such path | | pod forges `Tailscale-User-Login` over the attached socket | **instance-mcp must not trust that header on `/tools/attach` traffic** — today `AuthPolicy` accepts it from a loopback peer because `tailscale serve` is assumed to be the peer; over reverse attach the loopback peer is the attach client and the header originates in the pod. Identity of a request on the attached socket is the *grant*, nothing in the request | ## Split of work @@ -215,23 +217,23 @@ remainder of the grant TTL. | Phase | Deliverable | Depends on | |---|---|---| -| 4a | ✅ **Done 2026-09-26** — see "4a results" below and [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37). Protocol spike, no product code: `websocat` reverse WS from macmini into the pod's loopback; MCP round-trips (`sys_info`, `exec_start`/`exec_poll`) from a shell pointed at the pod's loopback port | a tailnet-enrolled pod | -| 4b | ✅ **Done 2026-09-26** — openab-pty [#38](https://github.com/openabdev/openab-pty/pull/38) (`/tools/attach` + loopback mux + `CLIENT-CONTRACT.md` §9); this repo: `ReverseAttachClient`, `ToolProfile` (`owner` / `sandbox` = no `exec*`), `AttachManager` + `POST/GET /attach`, `DELETE /attach/{id}`. Verified end to end **over the real tailnet** (laptop → macmini `POST /attach` via `tailscale serve` → macmini mints at the p1 pod and dials in → the session shell's `OPENAB_TOOLS_MCP_URL` → sandbox `tools/list` without `exec`, forced `exec` refused, `sys_info` in 12 ms, `DELETE /attach/{id}` → pod `ToolsDetach`). Found and fixed on the way: ATS in the .app bundle blocks plaintext tailnet URLs ([#8](https://github.com/openabdev/instance-mcp/pull/8)). Log: [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37) | 4a | +| 4a | ✅ **Done 2026-09-26** — see "4a results" below and [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37). Protocol spike, no product code: `websocat` reverse WS from the Mac into the pod's loopback; MCP round-trips (`sys_info`, `exec_start`/`exec_poll`) from a shell pointed at the pod's loopback port | a tailnet-enrolled pod | +| 4b | ✅ **Done 2026-09-26** — openab-pty [#38](https://github.com/openabdev/openab-pty/pull/38) (`/tools/attach` + loopback mux + `CLIENT-CONTRACT.md` §9); this repo: `ReverseAttachClient`, `ToolProfile` (`owner` / `sandbox` = no `exec*`), `AttachManager` + `POST/GET /attach`, `DELETE /attach/{id}`. Verified end to end **over the real tailnet** (laptop → the Mac `POST /attach` via `tailscale serve` → the Mac mints at the the k3s node pod and dials in → the session shell's `OPENAB_TOOLS_MCP_URL` → sandbox `tools/list` without `exec`, forced `exec` refused, `sys_info` in 12 ms, `DELETE /attach/{id}` → pod `ToolsDetach`). Found and fixed on the way: ATS in the .app bundle blocks plaintext tailnet URLs ([#8](https://github.com/openabdev/instance-mcp/pull/8)). Log: [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37) | 4a | | 4c | Connect + Remote "lend my Mac" UI + TTL + revoke; `exec` approval prompt | 4b + Connect/Remote | ## Verification (4a, measurement discipline) -- Test the **real hop**: macmini → pod inbound → loopback mux → agent. A laptop-direct call to - macmini proves nothing about the pod. +- Test the **real hop**: the Mac → pod inbound → loopback mux → agent. A laptop-direct call to + the Mac proves nothing about the pod. - From inside the shell: `curl http://127.0.0.1:/healthz` with **no** `*_PROXY` set. - One `sys_info` and one streaming call end to end; confirm `tools/list` under the `sandbox` profile does **not** contain `exec`, and that a forced `tools/call exec` is rejected. - Confirm from inside the shell that **no** outbound tailnet path exists (`curl` to another tailnet node fails) — this is the property the design is buying. -## 4a results (2026-09-26, real pod on p1, `oab-instance-mcp` 0.4.0 on macmini) +## 4a results (2026-09-26, real pod on the k3s node, `oab-instance-mcp` 0.4.0 on the Mac) -Full log in [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37). Stand-ins: `websocat -E -b ws-l:127.0.0.1:9100 tcp-l:127.0.0.1:9101` in the pod shell container (uid 1000) for `/tools/attach` + the loopback listener; `websocat -E -b ws://:9100 tcp:127.0.0.1:8796` on macmini as the reverse-attach client, into a bearer-only instance-mcp. +Full log in [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37). Stand-ins: `websocat -E -b ws-l:127.0.0.1:9100 tcp-l:127.0.0.1:9101` in the pod shell container (uid 1000) for `/tools/attach` + the loopback listener; `websocat -E -b ws://:9100 tcp:127.0.0.1:8796` on the Mac as the reverse-attach client, into a bearer-only instance-mcp. | Check | Result | |---|---| @@ -240,7 +242,7 @@ Full log in [openab-pty#37](https://github.com/openabdev/openab-pty/issues/37). | job-style call: `exec_start` + `exec_poll` | `running` → `exited exit 0`, output intact | | wrong bearer | 401 | | `tools/list` under the `sandbox` profile has no `exec*` | not testable in 4a (profiles are 4b); today the list has `exec`, `exec_start`, `exec_poll`, `exec_list`, `exec_cancel` | -| shell → any tailnet node, **host tailscaled running** | ❌ reached macmini/black/p1 and its own tailnet IP, as the node — see threat model row | +| shell → any tailnet node, **host tailscaled running** | ❌ reached the Mac and other tailnet nodes and its own tailnet IP, as the node — see threat model row | | shell → any tailnet node, **host tailscaled stopped** (intended shape) | ✅ timeout to every target incl. its own tailnet IP; loopback runtime and CNI internet egress unchanged | ### What was measured, as a picture @@ -253,14 +255,14 @@ sequenceDiagram participant SH as pod shell (uid 1000)
curl, no *_PROXY participant LB as pod loopback
127.0.0.1:9101 → ws-l :9100
(stand-in for mux + /tools/attach) participant SC as tailscale sidecar
userspace, inbound only - participant MM as macmini
websocat dialer → instance-mcp :8796 + participant MM as the Mac
websocat dialer → instance-mcp :8796 - MM->>SC: ws://100.111.174.31:9100 (WireGuard, direct LAN path via disco) + MM->>SC: ws://:9100 (WireGuard, direct LAN path via disco) SC->>LB: forward to 127.0.0.1:9100 — no `serve` config needed LB-->>MM: 101 Switching Protocols (attached) SH->>LB: POST /mcp initialize / tools/list / sys_info LB->>MM: frames over the reverse WS - MM-->>LB: results (macmini identity, 10 tools) + MM-->>LB: results (the Mac identity, 10 tools) LB-->>SH: 200, 18–62 ms end to end SH->>LB: exec_start → exec_poll ×2 LB-->>SH: running → exited exit 0 @@ -273,11 +275,11 @@ the intended deployment: ```mermaid flowchart LR - subgraph bad["❌ node IS a tailnet member (p1 as found)"] + subgraph bad["❌ node IS a tailnet member (the k3s node as found)"] direction TB sh1["pod shell"] -->|"default route"| cni1["cni0 (host)"] - cni1 -->|"ip rule 5270 → table 52
/32 per peer"| ts1["host tailscale0
masquerade as p1"] - ts1 -->|"src = p1's tailnet IP"| peers1["macmini :22 ✔ banner
black :22 ✔
own tailnet IP :8090 ✔"] + cni1 -->|"ip rule 5270 → table 52
/32 per peer"| ts1["host tailscale0
masquerade as the k3s node"] + ts1 -->|"src = the k3s node's tailnet IP"| peers1["the Mac :22 ✔ banner
other node :22 ✔
own tailnet IP :8090 ✔"] sc1["sidecar
inbound only"]:::idle end @@ -285,9 +287,9 @@ flowchart LR direction TB sh2["pod shell"] -->|"default route"| cni2["cni0 (host)"] cni2 -->|"no table 52, no tailscale0"| inet["internet via CNI
github.com 200"] - cni2 -.->|"100.64.0.0/10"| x["timeout — no path
macmini · black · p1 · own IP"] + cni2 -.->|"100.64.0.0/10"| x["timeout — no path
the Mac · other nodes · the k3s node · own IP"] sc2["sidecar
inbound only"] -->|"forwards inbound to
127.0.0.1:any"| lo2["pod loopback
runtime :8090 · attach :9100"] - mm2["macmini dials in"] --> sc2 + mm2["the Mac dials in"] --> sc2 end classDef idle fill:#eee,stroke:#999,color:#666,stroke-dasharray: 4 4 @@ -300,14 +302,14 @@ flowchart LR Carry-overs into 4b: - **Exactly one attach per session, enforced by the runtime.** Two dialers redialling the same session raced the loopback bind (`Address in use`), 418 failed attaches and a `TIME_WAIT` storm in minutes. Replace-with-close-code or refuse; never let the dialer spin. -- **Dialer must tolerate the pod going dark.** For ~8 min p1 (host and pod) could not complete TLS to the Tailscale control plane while other LAN hosts could; the pod showed `offline` and a fresh dialer had no path. Redial for the grant TTL with backoff. -- **A pod that is replaced needs a fresh `TS_AUTHKEY`.** The key stored in the k8s secret on p1 is already dead (`invalid key: API key does not exist`); the running sidecar lives on its persisted node state only. Reconnect-after-pod-restart therefore also depends on the operator's key hygiene, not just on Connect re-minting the verifier. +- **Dialer must tolerate the pod going dark.** For ~8 min the k3s node (host and pod) could not complete TLS to the Tailscale control plane while other LAN hosts could; the pod showed `offline` and a fresh dialer had no path. Redial for the grant TTL with backoff. +- **A pod that is replaced needs a fresh `TS_AUTHKEY`.** The key stored in the k8s secret on the k3s node is already dead (`invalid key: API key does not exist`); the running sidecar lives on its persisted node state only. Reconnect-after-pod-restart therefore also depends on the operator's key hygiene, not just on Connect re-minting the verifier. - openab-pty runtime as container PID 1 does not reap orphans (`` accumulate) — separate small fix there. ## Open questions - Reconnect semantics when the pod restarts under the same session name; who owns retry - (macmini, since it is the dialer). + (the Mac, since it is the dialer). - One attach per PTY session vs one per pod; per-session gives clean attribution in `agent.log` and matches the Connect grant model. - Whether `exec` in the `sandbox` profile is "absent" or "present but approval-gated"; start diff --git a/docs/tool-profiles.md b/docs/tool-profiles.md index 7d839a3..88f2833 100644 --- a/docs/tool-profiles.md +++ b/docs/tool-profiles.md @@ -142,10 +142,10 @@ the change is reported to the granter. | `POST /attach {"profile":"desktop"}` | a `desktop` grant | 202, runs as `observe`. The grant JSON has `"profile":"observe"`, `"requested_profile":"desktop"` and a `warning`. The daemon log and the audit file record the mapping | | `POST /attach` without `profile` | macOS `desktop`, Linux `observe` | `observe` on both | | `POST /attach` with `sandbox` or any other unknown name | 400 (two different messages) | 400 `profile must be one of [owner, observe]` on both | -| A grant stored as `desktop` (macOS `~/Library/Application Support/oab-instance-mcp/grants.json`, Linux `grants.json`) | resumed as `desktop` | kept and re-dialled under its id, as `observe`. `GET /attach` shows `profile: observe`, `requested_profile: desktop` and the `warning`. The file is rewritten with `profile: observe` and the requested name | +| A grant stored as `desktop` (macOS `~/Library/Application Support/oab-computer/grants.json`, Linux `grants.json`) | resumed as `desktop` | kept and re-dialled under its id, as `observe`. `GET /attach` shows `profile: observe`, `requested_profile: desktop` and the `warning`. The file is rewritten with `profile: observe` and the requested name | | A grant stored as `sandbox` or an unknown name | dropped | dropped (unchanged, #46) | | macOS `--switchboard-profile desktop` (a flag in the LaunchAgent plist) | `desktop` | serves `observe`, prints the warning to the agent log, and logs `switchboard: dialling … as observe (requested desktop)`. Any other unknown value still exits with status 64 | -| macOS `~/.config/oab-instance-mcp/switchboard.profile` containing `desktop` | copied into the plist | the installer writes `--switchboard-profile observe` and prints a warning. It does not edit your file | +| macOS `~/.config/oab-computer/switchboard.profile` containing `desktop` | copied into the plist | the installer writes `--switchboard-profile observe` and prints a warning. It does not edit your file | | Linux environment and config | no profile setting exists | nothing to migrate | `requested_profile` is in every grant JSON (it equals `profile` when nothing was mapped). @@ -161,8 +161,8 @@ string counts as absent on both platforms: the row keeps its stored profile. **Audit file.** Every mapping is also appended as one JSON line to an audit file, separate from the daemon log so it survives log rotation: macOS -`~/Library/Application Support/oab-instance-mcp/audit.jsonl`, Linux -`~/.local/state/oab-instance-mcp/audit.jsonl` (`$XDG_STATE_HOME` if set; override with +`~/Library/Application Support/oab-computer/audit.jsonl`, Linux +`~/.local/state/oab-computer/audit.jsonl` (`$XDG_STATE_HOME` if set; override with `MCP_AUDIT_FILE`, or `MCP_AUDIT_FILE=off` to disable). The file is mode 600 in a 0700 directory and is only appended to. A record looks like this: @@ -177,7 +177,7 @@ no `grant`, `session` or `principal`). A write failure is logged and never block **To restore shell or browser access**, grant `owner` explicitly: pick Owner in OpenAB Connect or OpenAB Remote, or send `POST /attach` with `"profile":"owner"`. The new grant replaces the old one for the same runtime and session. For a switchboard, choose `owner` in the menu bar's Configure… -(it asks for confirmation), or write `owner` to `~/.config/oab-instance-mcp/switchboard.profile` +(it asks for confirmation), or write `owner` to `~/.config/oab-computer/switchboard.profile` and re-run the installer, which bakes the file into the LaunchAgent plist. A hand-edited profile file alone does not apply: the daemon ignores it until the menu bar saves again, so a restart serves the profile in the plist. To keep `observe` and silence the warning, write `observe` there diff --git a/images/toolchain/Dockerfile b/images/toolchain/Dockerfile index cbd0a55..7ab9188 100644 --- a/images/toolchain/Dockerfile +++ b/images/toolchain/Dockerfile @@ -35,7 +35,7 @@ FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 -LABEL org.opencontainers.image.source="https://github.com/openabdev/instance-mcp" \ +LABEL org.opencontainers.image.source="https://github.com/openabdev/openab-computer" \ org.opencontainers.image.title="oab-toolchain" \ org.opencontainers.image.description="Sandbox toolchain image for the sandbox adapter (docs/adr/sandbox-adapter.md)" \ org.opencontainers.image.base.name="docker.io/library/debian:bookworm-slim" diff --git a/poc/pw-mcp/README.md b/poc/pw-mcp/README.md index 6321af0..42fe159 100644 --- a/poc/pw-mcp/README.md +++ b/poc/pw-mcp/README.md @@ -1,48 +1,48 @@ -# Step 1 PoC — Playwright MCP on macmini, driven from the tailnet +# Step 1 PoC — Playwright MCP on the Mac, driven from the tailnet > Done 2026-09-22. Design context: openabdev/openab#1544. > Proves the browser half of the relay-agent design before any Swift is written: -> a headed browser living in macmini's Aqua session, served as an MCP server on loopback. +> a headed browser living in the Mac's Aqua session, served as an MCP server on loopback. > > **Playwright MCP is loopback only (`127.0.0.1:8794`) and has no authentication.** It is not > mapped onto the tailnet and has no `:8443` access path (#115). Browser tools are reached only -> through `oab-instance-mcp` (`--upstream browser=http://127.0.0.1:8794/mcp`), which adds the +> through `oab-computer` (`--upstream browser=http://127.0.0.1:8794/mcp`), which adds the > Tailscale login check, the bearer token and the owner-only tool profile. The first version of > this PoC mapped `:8443` with `tailscale serve`; see > [Older installs](#older-installs-may-still-have-a-8443-mapping). -## What is deployed on macmini +## What is deployed on the Mac | Piece | Location | |---|---| -| `@playwright/mcp@0.0.82` (pinned, `npm install --save-exact`) | `~/.local/oab-instance-mcp/pw-mcp/` | -| Wrapper script | `~/.local/oab-instance-mcp/pw-mcp.sh` (from `pw-mcp.sh` here) | -| LaunchAgent (`gui/`) | `~/Library/LaunchAgents/dev.openab.instance-mcp.pw-mcp.plist` (rendered by `install.sh`) | -| Persistent browser profile | `~/.local/oab-instance-mcp/pw-profile/` | -| Screenshot / snapshot output | `~/.local/oab-instance-mcp/pw-output/` | -| Logs | `~/Library/Logs/oab-instance-mcp/pw-mcp.{log,err}` | +| `@playwright/mcp@0.0.82` (pinned, `npm install --save-exact`) | `~/.local/oab-computer/pw-mcp/` | +| Wrapper script | `~/.local/oab-computer/pw-mcp.sh` (from `pw-mcp.sh` here) | +| LaunchAgent (`gui/`) | `~/Library/LaunchAgents/dev.openab.computer.pw-mcp.plist` (rendered by `install.sh`) | +| Persistent browser profile | `~/.local/oab-computer/pw-profile/` | +| Screenshot / snapshot output | `~/.local/oab-computer/pw-output/` | +| Logs | `~/Library/Logs/oab-computer/pw-mcp.{log,err}` | | Listener | `127.0.0.1:8794` (loopback only; the wrapper passes `--host 127.0.0.1`) | | Tailnet URL | None. `install.sh` creates no `tailscale serve` mapping | Browser: Playwright's bundled Chromium (`~/Library/Caches/ms-playwright/chromium-1246`), **headed**, spawned as a child of the LaunchAgent pid, so it is a real window in the -logged-in desktop session. No Google Chrome is installed on macmini. +logged-in desktop session. No Google Chrome is installed on the Mac. -Client side: do not point a client at Playwright directly. Connect to `oab-instance-mcp`, which +Client side: do not point a client at Playwright directly. Connect to `oab-computer`, which re-serves the `browser_*` tools under its `owner` profile (top-level README, "Browser tools for lent sessions"). ## Install / operate ```sh -ssh macmini 'cd ~/src/oab-instance-mcp && bash poc/pw-mcp/install.sh' # idempotent +ssh 'cd ~/src/oab-computer && bash poc/pw-mcp/install.sh' # idempotent ``` ```sh -ssh macmini launchctl print gui/501/dev.openab.instance-mcp.pw-mcp | grep -E 'state|pid' -ssh macmini launchctl kickstart -k gui/501/dev.openab.instance-mcp.pw-mcp # restart -ssh macmini tail -20 ~/Library/Logs/oab-instance-mcp/pw-mcp.err -ssh macmini lsof -nP -iTCP:8794 -sTCP:LISTEN # one listener, on 127.0.0.1 +ssh launchctl print gui/501/dev.openab.computer.pw-mcp | grep -E 'state|pid' +ssh launchctl kickstart -k gui/501/dev.openab.computer.pw-mcp # restart +ssh tail -20 ~/Library/Logs/oab-computer/pw-mcp.err +ssh lsof -nP -iTCP:8794 -sTCP:LISTEN # one listener, on 127.0.0.1 ``` At the end, `install.sh` runs a read-only check (`tailscale serve status`) and prints a warning @@ -60,9 +60,9 @@ tailscale serve status # look for :8443 pointing at http://127.0.0. tailscale serve --https=8443 off # remove only that mapping ``` -Browser tools keep working through `oab-instance-mcp`, which reaches Playwright over loopback. +Browser tools keep working through `oab-computer`, which reaches Playwright over loopback. -## Measurements (first PoC, laptop → macmini through the since-removed :8443 mapping) +## Measurements (first PoC, laptop → the Mac through the since-removed :8443 mapping) | Call | Latency | |---|---| @@ -80,7 +80,7 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e 1. **`--allowed-hosts` is an exact string match on the `Host` header, port included.** `127.0.0.1` does not cover `127.0.0.1:8794`. List every form a client will send. - (oab-instance-mcp sends the bare `127.0.0.1`, which the wrapper allows.) + (oab-computer sends the bare `127.0.0.1`, which the wrapper allows.) 2. **Default browser channel is `chrome`, not bundled Chromium.** Without `--browser chromium` the first tool call fails with `Chromium distribution 'chrome' is not found at /Applications/Google Chrome.app`. The @@ -103,7 +103,7 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e - Playwright MCP has no authentication. It listens on `127.0.0.1:8794` only, so only processes on this Mac reach it. There is no `tailscale serve` mapping for it and no `:8443` access path. -- Browser tools reach other machines only through `oab-instance-mcp`'s `--upstream`, behind its +- Browser tools reach other machines only through `oab-computer`'s `--upstream`, behind its login check and bearer token, and only under the `owner` profile: `observe` gets no browser tool (#59). A switchboard's per-caller allowlist applies on top. - `browser_run_code_unsafe` is in the tool list: arbitrary Playwright JS in the logged-in @@ -112,12 +112,12 @@ Latency is not the bottleneck; the LLM round trip is. Good enough for the thin-e ## What this proves for the design - The Aqua-session LaunchAgent + loopback listener shape works and needs no custom transport. - The Swift `oab-instance-mcp` re-serves the browser tools to remote callers. + The Swift `oab-computer` re-serves the browser tools to remote callers. - Playwright MCP covers the browser half completely; the Swift daemon does not need to touch CDP. `connectOverCDP` from the sandbox was never needed. ## Next -The Swift `oab-instance-mcp` daemon (see top-level README) runs beside this on `127.0.0.1:8795`, +The Swift `oab-computer` daemon (see top-level README) runs beside this on `127.0.0.1:8795`, same LaunchAgent pattern, with Tailscale-identity auth, and re-serves these tools through `--upstream`. Lent callers get the browser only under `owner` (#59). diff --git a/poc/pw-mcp/check-legacy-mapping.sh b/poc/pw-mcp/check-legacy-mapping.sh index 1b91a5e..5fe65f8 100755 --- a/poc/pw-mcp/check-legacy-mapping.sh +++ b/poc/pw-mcp/check-legacy-mapping.sh @@ -12,7 +12,7 @@ warn_legacy_8443_mapping() { echo "Tailscale :8443 check: could not check (Tailscale CLI not found)"; return 0 fi # TERM_PROGRAM makes the Tailscale.app binary act as a CLI. - if ! out=$(TERM_PROGRAM=oab-pw-mcp-installer "$cli" serve status 2>/dev/null) || [ -z "$out" ]; then + if ! out=$(TERM_PROGRAM=oab-computer-pw-mcp-installer "$cli" serve status 2>/dev/null) || [ -z "$out" ]; then echo "Tailscale :8443 check: could not check (serve status unreadable)"; return 0 fi # A mapping is a non-indented "https://...:8443" header followed by a handler line to 8794. @@ -23,7 +23,7 @@ warn_legacy_8443_mapping() { cat <<'MSG' WARNING: this Mac still maps tailnet port :8443 to 127.0.0.1:8794 (Playwright MCP). Playwright MCP has no authentication, so any device on your tailnet can drive the browser, - including browser_run_code_unsafe. Browser tools do not need it: oab-instance-mcp reaches + including browser_run_code_unsafe. Browser tools do not need it: oab-computer reaches Playwright over loopback. This installer does not remove it. To remove it, run: tailscale serve --https=8443 off MSG diff --git a/poc/pw-mcp/install.sh b/poc/pw-mcp/install.sh index b0cd935..8b9fa77 100755 --- a/poc/pw-mcp/install.sh +++ b/poc/pw-mcp/install.sh @@ -1,12 +1,12 @@ #!/bin/bash -# Install the Playwright MCP LaunchAgent beside oab-instance-mcp (run ON the target Mac). +# Install the Playwright MCP LaunchAgent beside oab-computer (run ON the target Mac). # Renders the plist with this user's $HOME (launchd does not expand ~), installs the # wrapper and pins @playwright/mcp. Playwright MCP listens on 127.0.0.1:8794 only and is -# NOT exposed to the tailnet; browser tools are reached through oab-instance-mcp (#115). +# NOT exposed to the tailnet; browser tools are reached through oab-computer (#115). set -euo pipefail -LABEL=dev.openab.instance-mcp.pw-mcp -BASE="$HOME/.local/oab-instance-mcp" -LOGS="$HOME/Library/Logs/oab-instance-mcp" +LABEL=dev.openab.computer.pw-mcp +BASE="$HOME/.local/oab-computer" +LOGS="$HOME/Library/Logs/oab-computer" HERE="$(cd "$(dirname "$0")" && pwd)" TS=/Applications/Tailscale.app/Contents/MacOS/Tailscale # read by warn_legacy_8443_mapping # shellcheck source=check-legacy-mapping.sh disable=SC2034 @@ -38,4 +38,4 @@ for _ in $(seq 1 40); do launchctl print "gui/$UID_/$LABEL" >/dev/null 2>&1 || b launchctl bootstrap "gui/$UID_" "$PLIST" launchctl print "gui/$UID_/$LABEL" | grep -E 'state|pid =' warn_legacy_8443_mapping -echo "Browser tools: reach them through oab-instance-mcp with --upstream browser=http://127.0.0.1:8794/mcp (the main installer adds it when this agent is loaded)." +echo "Browser tools: reach them through oab-computer with --upstream browser=http://127.0.0.1:8794/mcp (the main installer adds it when this agent is loaded)." diff --git a/poc/pw-mcp/pw-mcp.sh b/poc/pw-mcp/pw-mcp.sh index 756f01c..263eec2 100755 --- a/poc/pw-mcp/pw-mcp.sh +++ b/poc/pw-mcp/pw-mcp.sh @@ -1,7 +1,7 @@ #!/bin/zsh -# Playwright MCP for oab-instance-mcp step-1 PoC. Loopback only (127.0.0.1:8794); never exposed with tailscale serve (#115). +# Playwright MCP for oab-computer step-1 PoC. Loopback only (127.0.0.1:8794); never exposed with tailscale serve (#115). export PATH=/opt/homebrew/bin:/usr/bin:/bin -BASE="$HOME/.local/oab-instance-mcp" +BASE="$HOME/.local/oab-computer" DNSNAME="${TS_DNSNAME:-$(/Applications/Tailscale.app/Contents/MacOS/Tailscale status --self --peers=false --json | python3 -c 'import json,sys;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')}" cd "$BASE/pw-mcp" exec ./node_modules/.bin/playwright-mcp \ diff --git a/poc/reverse-attach-linux/Cargo.lock b/poc/reverse-attach-linux/Cargo.lock index 856db67..0fff4eb 100644 --- a/poc/reverse-attach-linux/Cargo.lock +++ b/poc/reverse-attach-linux/Cargo.lock @@ -175,6 +175,17 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "oab-computer" +version = "0.12.0" +dependencies = [ + "base64", + "jpeg-encoder", + "rustls", + "serde_json", + "tungstenite", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -244,17 +255,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "reverse-attach" -version = "0.12.0" -dependencies = [ - "base64", - "jpeg-encoder", - "rustls", - "serde_json", - "tungstenite", -] - [[package]] name = "ring" version = "0.17.14" diff --git a/poc/reverse-attach-linux/Cargo.toml b/poc/reverse-attach-linux/Cargo.toml index 8177fbe..0d6ab85 100644 --- a/poc/reverse-attach-linux/Cargo.toml +++ b/poc/reverse-attach-linux/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "reverse-attach" +name = "oab-computer" # Matches the app release version. Releases still compile in OAB_RELEASE_VERSION from the tag (#74). version = "0.12.0" edition = "2021" diff --git a/poc/reverse-attach-linux/README.md b/poc/reverse-attach-linux/README.md index 5bc001e..dd35766 100644 --- a/poc/reverse-attach-linux/README.md +++ b/poc/reverse-attach-linux/README.md @@ -1,4 +1,4 @@ -# reverse-attach (Linux hands node, Rust PoC) +# oab-computer (Linux hands node, Rust PoC) Step-by-step install on a fresh box: [`docs/linux-setup.md`](../../docs/linux-setup.md). @@ -43,7 +43,7 @@ mirroring the Swift `ReverseAttachClient` / `POST /attach` contract: `exec_start` + `exec_poll`. Under `observe` a long `bash` is an *unknown tool*. - Background jobs (`src/jobs/`, #32 item 4, `owner` only): `exec_start` / `exec_poll` / `exec_list` / `exec_cancel`, same arguments and `structuredContent` as the Swift tools, with - `bash -c` in a new session. stdout/stderr go to `$XDG_STATE_HOME/oab-instance-mcp/jobs/.out` + `bash -c` in a new session. stdout/stderr go to `$XDG_STATE_HOME/oab-computer/jobs/.out` / `.err` (dir 700, files 600, never truncated); `exec_poll` reads by byte offset, at most 1 MiB per stream per poll. A job stays `running` until its shell has exited and its process group is empty; the shell is reaped only then, so cancel and timeout can signal the group @@ -70,7 +70,7 @@ bash smoke.sh # RESULT: 55 passed, 0 failed | mint path | `ttl_secs=60` seen by the runtime's mint endpoint; `expires_in_secs=60` in the grant | | control contract | `POST /attach` 202 with every Connect-required grant field; `GET /attach` wrapper; `GET /attach/{id}`; `DELETE` 204 + removal; principal and dynamic expiry | | redial / stop | close `1000` → redialed after 1 s; close `4010` → `state=ended, ended=revoked`; wrong secret → `ended=handshake_rejected_401` with exactly one attempt; unreachable runtime → `ended=deadline` | -| MCP | `serverInfo.name=instance-mcp-rpi`; owner list = `sys_info,screenshot,exec`; `sys_info.hostname=`; `exec` ran on the node; unknown tool / method → `-32601` at the time (since #78: unknown tool `-32602`, method `-32601`); notification silent; ping → pong (since #78: `{}`) | +| MCP | `serverInfo.name=instance-mcp-rpi` (named oab-computer-rpi since the 0.12.0 rename); owner list = `sys_info,screenshot,exec`; `sys_info.hostname=`; `exec` ran on the node; unknown tool / method → `-32601` at the time (since #78: unknown tool `-32602`, method `-32601`); notification silent; ping → pong (since #78: `{}`) | | desktop (removed in #59) | list = `sys_info,screenshot`; forced `exec` → error, not executed (2026-09-28 run). Since #59 `smoke.sh` checks that a `desktop` request runs as `observe` with `requested_profile` and a `warning`, and that a stored `desktop` grant resumes as `observe` | | close handshake | client echoes the Close frame (was a bare EOF before the `socket.flush()` fix in `dial_loop`) | @@ -96,7 +96,7 @@ JPEG in 0.07 s vs a 2.0 MB PNG in 1.0 s. `sys_info` carries the `host` / `displays` / `permissions.screen_recording` / `agent.version` fields the Screens pane reads. -On a Raspberry Pi: systemd user unit `oab-instance-mcp.service` (BIND 127.0.0.1:8795, seat env +On a Raspberry Pi: systemd user unit `oab-computer.service` (BIND 127.0.0.1:8795, seat env `WAYLAND_DISPLAY=wayland-0`, linger on) + `tailscale serve --bg --https=8444 http://127.0.0.1:8795` → `https://..ts.net:8444/mcp`. Verified from the laptop: wrong token 401, initialize 200 with `Mcp-Session-Id`, `sys_info` host= displays=1, Connect-shaped screenshot @@ -118,7 +118,7 @@ config. Verified from the laptop: click into a field → `type` → screenshot s Same pattern as the Mac: `@playwright/mcp` runs beside the daemon on loopback and the daemon re-serves its tools under its own `tools/list`, filtered by the connection's profile. -- `pw-mcp.sh` + systemd user unit `oab-pw-mcp.service`: `@playwright/mcp@0.0.82` pinned (same +- `pw-mcp.sh` + systemd user unit `oab-computer-pw-mcp.service`: `@playwright/mcp@0.0.82` pinned (same as `poc/pw-mcp`), **headed** Chromium launched into the labwc seat (`WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, `DISPLAY=:0` for labwc's Xwayland), using the distro Chromium (`--executable-path /usr/bin/chromium`, arm64-native, no Playwright download), persistent @@ -131,7 +131,7 @@ re-serves its tools under its own `tools/list`, filtered by the connection's pro - Profile filter (#59): `owner` sees every upstream tool (all 32, and any added later); `observe` sees none. `profile_conformance` checks both against the 32-tool fixture in `conformance/profile_vectors.json` through a fake upstream. -- Chromium managed policy `/etc/chromium/policies/managed/oab-instance-mcp.json` blocks +- Chromium managed policy `/etc/chromium/policies/managed/oab-computer.json` blocks camera/mic/notification/geolocation prompts: YouTube triggered the xdg-desktop-portal "Allow app to use the Camera?" dialog, which nothing but `mouse` could dismiss. @@ -144,14 +144,14 @@ on the Pi's desktop, so `screenshot` / Connect's Screens pane show what the agen ## Grant persistence (#12) -Live grants are written to `MCP_GRANTS_FILE` (default `$XDG_STATE_HOME/oab-instance-mcp/grants.json`, +Live grants are written to `MCP_GRANTS_FILE` (default `$XDG_STATE_HOME/oab-computer/grants.json`, `off` disables), mode 600 in a 0700 directory, replaced atomically on every create / replace / `DELETE` / terminal end. On start, grants still inside their deadline are re-dialled under the same id. Smoke covers `kill -9` → restart → redial, same id, secret absent from `GET /attach`, and a revoked grant not resumed. A `desktop` request or stored row runs as `observe` (#59), and each mapping is appended as one -JSON line to `MCP_AUDIT_FILE` (default `$XDG_STATE_HOME/oab-instance-mcp/audit.jsonl`, `off` +JSON line to `MCP_AUDIT_FILE` (default `$XDG_STATE_HOME/oab-computer/audit.jsonl`, `off` disables), mode 600. Smoke checks the `attach` and `resume` records. ## Not yet (vs the Swift implementation) diff --git a/poc/reverse-attach-linux/pw-mcp.sh b/poc/reverse-attach-linux/pw-mcp.sh index 946ca8d..e614232 100644 --- a/poc/reverse-attach-linux/pw-mcp.sh +++ b/poc/reverse-attach-linux/pw-mcp.sh @@ -12,7 +12,7 @@ # - PW_NODE_BIN: the directory of a non-system node (fnm/nvm) so npm/npx resolve. export PATH="${PW_NODE_BIN:+$PW_NODE_BIN:}/usr/local/bin:/usr/bin:/bin" export LANG="${LANG:-en_US.UTF-8}" -BASE="$HOME/.local/oab-instance-mcp" +BASE="$HOME/.local/oab-computer" export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" export WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-wayland-0}" export DISPLAY="${DISPLAY:-:0}" diff --git a/poc/reverse-attach-linux/smoke.sh b/poc/reverse-attach-linux/smoke.sh index 3f4bd84..f3539b0 100755 --- a/poc/reverse-attach-linux/smoke.sh +++ b/poc/reverse-attach-linux/smoke.sh @@ -3,15 +3,15 @@ # Runs entirely on loopback of the node. Every step is time-bounded. set -u cd "$(dirname "$0")" -BIN=./target/release/reverse-attach +BIN=./target/release/oab-computer LOG=/tmp/mock-runtime.jsonl -RA_LOG=/tmp/reverse-attach.log +RA_LOG=/tmp/oab-computer-smoke.log pass=0; fail=0 ok() { echo "PASS $1"; pass=$((pass+1)); } bad() { echo "FAIL $1"; fail=$((fail+1)); } check(){ if eval "$2"; then ok "$1"; else bad "$1 :: $2"; fi; } -pkill -x reverse-attach 2>/dev/null; pkill -f mock_runtime.py 2>/dev/null; sleep 0.3 +pkill -f "^\./target/release/oab-computer" 2>/dev/null; pkill -f mock_runtime.py 2>/dev/null; sleep 0.3 rm -f "$LOG" "$RA_LOG" # Never touch the real ~/.local/state grants file from a test run. GRANTS_DIR=$(mktemp -d) @@ -115,7 +115,7 @@ if q=="killed": sc=byid(13)["result"]["structuredContent"]; print(f'{sc["state"]},{sc.get("exit_code")}') EOF } -check "initialize → serverInfo instance-mcp-rpi" "[[ \$(py init) == instance-mcp-rpi ]]" +check "initialize → serverInfo oab-computer-rpi" "[[ \$(py init) == oab-computer-rpi ]]" check "owner tools/list = sys_info,screenshot,bash,mouse,key,exec_*" "[[ \$(py tools) == sys_info,screenshot,bash,mouse,key,exec_start,exec_poll,exec_list,exec_cancel ]]" check "sys_info hostname = $(hostname)" "[[ \$(py sys) == $(hostname) ]]" # Release builds set OAB_RELEASE_VERSION at compile time and EXPECT_AGENT_VERSION here (#74); @@ -134,7 +134,7 @@ check "ping → pong" "[[ \$(py pong) == True ]]" JOB=$(py job) check "exec_start → job_id" "[[ '$JOB' == job-* ]]" check "exec_poll by offset returns only new output" "[[ \$(py poll2) == True ]]" -check "job log tee'd under XDG_STATE_HOME" "[[ -s '$GRANTS_DIR/oab-instance-mcp/jobs/$JOB.out' ]]" +check "job log tee'd under XDG_STATE_HOME" "[[ -s '$GRANTS_DIR/oab-computer/jobs/$JOB.out' ]]" check "job survived the attach drop (polled on attach 2)" "[[ \$(py survived) == running ]]" check "exec_cancel → killed with exit code" "[[ \$(py killed) == killed,137 ]]" @@ -234,6 +234,6 @@ start_ra check "a revoked grant is not resumed" "[[ \$($C 127.0.0.1:8790/attach | python3 -c 'import sys,json;print(len(json.load(sys.stdin)[\"grants\"]))') == 0 ]]" echo -echo "reverse-attach stderr:"; sed 's/^/ /' $RA_LOG +echo "oab-computer stderr:"; sed 's/^/ /' $RA_LOG echo "RESULT: $pass passed, $fail failed" [[ $fail == 0 ]] diff --git a/poc/reverse-attach-linux/src/attach/grant_secrecy_tests.rs b/poc/reverse-attach-linux/src/attach/grant_secrecy_tests.rs index 68989d6..67c7df7 100644 --- a/poc/reverse-attach-linux/src/attach/grant_secrecy_tests.rs +++ b/poc/reverse-attach-linux/src/attach/grant_secrecy_tests.rs @@ -12,7 +12,7 @@ fn grant_json_never_carries_the_attach_secret() { for ended in [None, Some("revoked".to_string())] { let g = GrantInfo { id: "grant-1-1".to_string(), - runtime: "ws://10.0.0.2:8090".to_string(), + runtime: "ws://198.51.100.2:8090".to_string(), session: "s1".to_string(), profile: "owner".to_string(), requested_profile: "owner".to_string(), diff --git a/poc/reverse-attach-linux/src/attach/store.rs b/poc/reverse-attach-linux/src/attach/store.rs index 1f4296a..69366f5 100644 --- a/poc/reverse-attach-linux/src/attach/store.rs +++ b/poc/reverse-attach-linux/src/attach/store.rs @@ -10,8 +10,8 @@ //! at the same trust level as the bearer token file beside it (both 600 in the //! daemon user's home). Ended, cancelled and expired grants are never written. //! -//! Path: `MCP_GRANTS_FILE`, else `$XDG_STATE_HOME/oab-instance-mcp/grants.json`, -//! else `~/.local/state/oab-instance-mcp/grants.json`. `MCP_GRANTS_FILE=off` +//! Path: `MCP_GRANTS_FILE`, else `$XDG_STATE_HOME/oab-computer/grants.json`, +//! else `~/.local/state/oab-computer/grants.json`. `MCP_GRANTS_FILE=off` //! disables persistence. use std::fs; @@ -65,7 +65,7 @@ fn default_path() -> Option { let base = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?; - Some(base.join("oab-instance-mcp/grants.json")) + Some(base.join("oab-computer/grants.json")) } /// Snapshot the registry's live grants to disk. No-op when persistence is off. diff --git a/poc/reverse-attach-linux/src/audit.rs b/poc/reverse-attach-linux/src/audit.rs index 8e237bc..f119d48 100644 --- a/poc/reverse-attach-linux/src/audit.rs +++ b/poc/reverse-attach-linux/src/audit.rs @@ -5,8 +5,8 @@ //! it. The file is append-only and separate from the journal, so the record //! survives log rotation. Same record shape as the macOS daemon. //! -//! Path: `MCP_AUDIT_FILE`, else `$XDG_STATE_HOME/oab-instance-mcp/audit.jsonl`, -//! else `~/.local/state/oab-instance-mcp/audit.jsonl`. `MCP_AUDIT_FILE=off` +//! Path: `MCP_AUDIT_FILE`, else `$XDG_STATE_HOME/oab-computer/audit.jsonl`, +//! else `~/.local/state/oab-computer/audit.jsonl`. `MCP_AUDIT_FILE=off` //! disables it. Mode 600 in a 0700 directory. Writing is best-effort: a failure //! is logged and never blocks the grant. @@ -36,7 +36,7 @@ fn default_path() -> Option { let base = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?; - Some(base.join("oab-instance-mcp/audit.jsonl")) + Some(base.join("oab-computer/audit.jsonl")) } /// One `profile_mapped` record. `source` is `attach` or `resume`. diff --git a/poc/reverse-attach-linux/src/jobs/mod.rs b/poc/reverse-attach-linux/src/jobs/mod.rs index 9f3b421..46c32ed 100644 --- a/poc/reverse-attach-linux/src/jobs/mod.rs +++ b/poc/reverse-attach-linux/src/jobs/mod.rs @@ -2,8 +2,8 @@ //! (same contract as the Swift `JobRegistry`). //! //! Each job's stdout and stderr go straight to files, `

/.out` and -//! `.err` (`$XDG_STATE_HOME/oab-instance-mcp/jobs/`, else -//! `~/.local/state/oab-instance-mcp/jobs/`). The files are the single source of +//! `.err` (`$XDG_STATE_HOME/oab-computer/jobs/`, else +//! `~/.local/state/oab-computer/jobs/`). The files are the single source of //! truth: `exec_poll` reads them by byte offset, nothing is kept in memory or //! truncated, and a human can `tail -f` them. //! @@ -304,7 +304,7 @@ fn default_dir() -> Option { .filter(|v| !v.is_empty()) .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?; - Some(base.join("oab-instance-mcp/jobs")) + Some(base.join("oab-computer/jobs")) } /// Unit tests never write into the real state directory. diff --git a/poc/reverse-attach-linux/src/jobs/tools.rs b/poc/reverse-attach-linux/src/jobs/tools.rs index 6bfa424..e70f05c 100644 --- a/poc/reverse-attach-linux/src/jobs/tools.rs +++ b/poc/reverse-attach-linux/src/jobs/tools.rs @@ -48,7 +48,7 @@ pub(crate) fn tool_specs() -> Vec { a process that left the group, e.g. with setsid). Output is read from the \ job's log files (never truncated; at most 1 MiB per stream per poll, poll \ again from the returned offsets for the rest). The files are \ - $XDG_STATE_HOME/oab-instance-mcp/jobs/.out/.err (default \ + $XDG_STATE_HOME/oab-computer/jobs/.out/.err (default \ ~/.local/state/...); `out_path`/`err_path` give the exact paths.", "inputSchema": { "type": "object", diff --git a/poc/reverse-attach-linux/src/mcp.rs b/poc/reverse-attach-linux/src/mcp.rs index b7ae9f4..dfe2d56 100644 --- a/poc/reverse-attach-linux/src/mcp.rs +++ b/poc/reverse-attach-linux/src/mcp.rs @@ -66,7 +66,7 @@ impl Upstream { let r = self.post(&json!({ "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, - "clientInfo": {"name": "instance-mcp-rpi", "version": AGENT_VERSION}} + "clientInfo": {"name": "oab-computer-rpi", "version": AGENT_VERSION}} }))?; if !(200..300).contains(&r.status) { return Err(format!( @@ -476,7 +476,7 @@ pub(crate) fn answer_request( "protocolVersion": negotiated, "capabilities": { "tools": { "listChanged": false } }, "serverInfo": { - "name": "instance-mcp-rpi", + "name": "oab-computer-rpi", "version": AGENT_VERSION }, "instructions": instructions(profile) diff --git a/poc/reverse-attach-linux/src/platform/linux/seat.rs b/poc/reverse-attach-linux/src/platform/linux/seat.rs index 98e046b..488c287 100644 --- a/poc/reverse-attach-linux/src/platform/linux/seat.rs +++ b/poc/reverse-attach-linux/src/platform/linux/seat.rs @@ -1,11 +1,11 @@ //! Input devices that live as long as the daemon, on the wlroots seat. //! -//! Why this exists: a seat with no physical input (black's headless sway, a Pi with no +//! Why this exists: a seat with no physical input (the server's headless sway, a Pi with no //! mouse or keyboard) advertises no pointer/keyboard capability, so clients such as Chromium //! hold no `wl_pointer`/`wl_keyboard`. `wlrctl` and `wtype` each create a virtual device, //! send their events and exit within milliseconds: the capability flickers on, the client's //! `get_pointer`/`get_keyboard` round-trip loses the race, and every event is dropped — while -//! the tool exits 0 and the MCP call reports `ok: true`. Measured on black (2026-09-29): +//! the tool exits 0 and the MCP call reports `ok: true`. Measured on the server (2026-09-29): //! a transient `wtype` was ignored; the same keys typed while another virtual keyboard stayed //! connected arrived. //! @@ -32,7 +32,7 @@ use std::time::{Duration, Instant}; use crate::platform::desktop::{Button, DesktopError}; /// How long a freshly created device is given before its first event, so clients can see -/// the new capability and bind `wl_pointer` / `wl_keyboard`. 1.5 s worked on black; 400 ms +/// the new capability and bind `wl_pointer` / `wl_keyboard`. 1.5 s worked on a headless server; 400 ms /// is ample for one round-trip on a local socket. const SETTLE: Duration = Duration::from_millis(400); @@ -610,7 +610,7 @@ fn anchor_slot() -> &'static Mutex> { } /// `wtype -s` multiplies milliseconds by 1000 in a C `int`: anything above ~2147 s wraps -/// negative and wtype exits at once (seen on black with 3600000). 30 minutes, respawned. +/// negative and wtype exits at once (seen on a headless server with 3600000). 30 minutes, respawned. const ANCHOR_MS: &str = "1800000"; /// Make sure the keyboard capability is up before `wtype` types. Respawns the anchor when it diff --git a/poc/reverse-attach-linux/src/tools/sysinfo.rs b/poc/reverse-attach-linux/src/tools/sysinfo.rs index 0ac01a9..848318e 100644 --- a/poc/reverse-attach-linux/src/tools/sysinfo.rs +++ b/poc/reverse-attach-linux/src/tools/sysinfo.rs @@ -5,10 +5,10 @@ use serde_json::{json, Value}; use crate::platform; -pub const AGENT_NAME: &str = "instance-mcp-rpi"; +pub const AGENT_NAME: &str = "oab-computer-rpi"; /// The release version this binary reports (`sys_info.agent.version`, MCP `serverInfo`). /// The release workflow sets `OAB_RELEASE_VERSION` from the tag, which it requires to equal -/// `let version` in `Sources/oab-instance-mcp/main.swift`, so the tag is the only source. +/// `let version` in `Sources/oab-computer/main.swift`, so the tag is the only source. /// A build without it (local, `cargo test`) reports `-dev`, never a release /// number; the crate version in Cargo.toml is not bumped per release (#74). pub const AGENT_VERSION: &str = match option_env!("OAB_RELEASE_VERSION") { diff --git a/scripts/assemble-app.sh b/scripts/assemble-app.sh index 415a8b0..b36df43 100755 --- a/scripts/assemble-app.sh +++ b/scripts/assemble-app.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Assemble the minimal oab-instance-mcp.app bundle around a prebuilt binary. +# Assemble the minimal oab-computer.app bundle around a prebuilt binary. # # This script does NOT sign. Local deploy.sh signs with Apple Development; the # release workflow signs with Developer ID Application. Keeping assembly separate @@ -12,7 +12,7 @@ set -euo pipefail BIN="${1:?usage: assemble-app.sh [version]}" OUT="${2:?usage: assemble-app.sh [version]}" VERSION="${3:-}" -BUNDLE_ID="${BUNDLE_ID:-dev.openab.instance-mcp}" +BUNDLE_ID="${BUNDLE_ID:-dev.openab.computer}" [ -f "$BIN" ] && [ -x "$BIN" ] || { echo "assemble-app: binary is missing or not executable: $BIN" >&2 @@ -49,8 +49,8 @@ TMP=$(mktemp -d "$PARENT/.${NAME}.assemble.XXXXXX") trap 'rm -rf "$TMP"' EXIT APP="$TMP/$NAME" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -/usr/bin/ditto "$BIN" "$APP/Contents/MacOS/oab-instance-mcp" -chmod 755 "$APP/Contents/MacOS/oab-instance-mcp" +/usr/bin/ditto "$BIN" "$APP/Contents/MacOS/oab-computer" +chmod 755 "$APP/Contents/MacOS/oab-computer" # Makes the advanced .app.zip artifact self-contained: after unzipping, run # app/Contents/Resources/install-prebuilt.sh app # The signed/notarized .pkg remains the normal one-click install path. @@ -58,11 +58,11 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) cp "$SCRIPT_DIR/install-prebuilt.sh" "$APP/Contents/Resources/install-prebuilt.sh" chmod 755 "$APP/Contents/Resources/install-prebuilt.sh" -# User-visible name. The bundle id, executable and .app file name stay -# oab-instance-mcp (TCC grants and installed paths depend on them, see #63). +# User-visible name. The bundle id (dev.openab.computer), executable and .app file name are +# oab-computer (TCC grants and installed paths depend on them, see #63). # macOS shows a name other than the file name only when the bundle localizes # it AND the file name equals the base CFBundleDisplayName, so the base names -# stay oab-instance-mcp and en.lproj carries "OpenAB Computer". +# stay oab-computer and en.lproj carries "OpenAB Computer". DISPLAY_NAME="OpenAB Computer" mkdir -p "$APP/Contents/Resources/en.lproj" cat >"$APP/Contents/Resources/en.lproj/InfoPlist.strings" <"$APP/Contents/Info.plist" < CFBundleIdentifier$BUNDLE_ID - CFBundleNameoab-instance-mcp - CFBundleDisplayNameoab-instance-mcp + CFBundleNameoab-computer + CFBundleDisplayNameoab-computer LSHasLocalizedDisplayName CFBundleDevelopmentRegionen - CFBundleExecutableoab-instance-mcp + CFBundleExecutableoab-computer CFBundlePackageTypeAPPL CFBundleShortVersionString$VERSION CFBundleVersion$VERSION diff --git a/scripts/check-no-private-identifiers.sh b/scripts/check-no-private-identifiers.sh new file mode 100755 index 0000000..8f0616a --- /dev/null +++ b/scripts/check-no-private-identifiers.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Fails if a tracked text file contains a private identifier: a private host name, the tailnet name, +# a real-looking tailnet or LAN address, a personal email or login, or a token-looking string. +# Use placeholders instead: ..ts.net, 100.64.0.x, 192.0.2.x, 198.51.100.x, +# user@example.com. Intentional exceptions are explicit, one per line, in +# scripts/private-identifiers.allow (" # reason"). +# Usage: scripts/check-no-private-identifiers.sh [root] (root: a git checkout or a plain directory) +set -uo pipefail +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +cd "$ROOT" || exit 2 +ALLOW="$ROOT/scripts/private-identifiers.allow" +SELF=scripts/check-no-private-identifiers.sh +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -z "${NO_GIT:-}" ]; then + git ls-files +else + find . -type f -not -path "./.git/*" -not -path "./.build/*" -not -path "*/target/*" | sed 's|^\./||' +fi | grep -v -e "^$SELF\$" -e '^scripts/private-identifiers.allow$' -e '^scripts/test-check-no-private-identifiers.sh$' -e 'Cargo.lock$' >"$T/files" + +# Matched case-insensitively. Host names are whole words. +HOST='(^|[^A-Za-z0-9_-])(black|rpi1|rpi2|minipc|macmini|lzmacmini|oracle-vm|p1|zsv-01|mbp|pahuds-macbook-neo)([^A-Za-z0-9_-]|$)' +HOSTSSH='(ssh|scp|rsync)( +-[A-Za-z]+( +[^ ]+)?)* +([a-z0-9]+@)?(bl|mini|muse)([: ]|$)|(^|[^A-Za-z0-9_-])(bl|mini|muse)\.(local|lan|tail[0-9a-f]*\.ts\.net)' +TAILNET='tailb836bb' +TSNET='[A-Za-z0-9-]+\.[A-Za-z0-9-]+\.ts\.net' +CGNAT='(^|[^0-9.])100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]+\.[0-9]+' +RFC1918='(^|[^0-9.])(192\.168\.[0-9]+\.[0-9]+|10\.[0-9]+\.[0-9]+\.[0-9]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]+\.[0-9]+)' +EMAIL='[A-Za-z0-9._+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}' +LOGIN='pahudnet|/Users/pahud|pahud@' +TOKEN='ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|tskey-(auth|api|client)-[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY' + +# Placeholder filters: read the matched text on stdin, print what is NOT a placeholder. +f_tsnet() { awk -F. '{ l = $(NF-2) } l != "tail" && l != "tail1" && l != "tail1234" && l != "t" && l != "example" { print }'; } +f_ip() { grep -vE '^100\.64\.0\.[0-9]$|^100\.64\.0\.0$'; } +f_email() { grep -viE '@(example\.(com|org|invalid)|b\.c|users\.noreply\.github\.com)$'; } + +: >"$T/hits" +scan() { # name regex [filter-function] + local name="$1" re="$2" filt="${3:-cat}" f l n + while IFS= read -r f; do + [ -f "$f" ] || continue + grep -nIiE -- "$re" "$f" 2>/dev/null | while IFS= read -r l; do + n="${l%%:*}" + # keep the line only if some match survives the placeholder filter + printf '%s\n' "${l#*:}" | grep -oiE -- "$re" | sed -E 's/^[^0-9A-Za-z]//' | "$filt" | grep -q . || continue + echo "$name $f:$n" + done + done <"$T/files" >>"$T/hits" +} +scan host "$HOST" +scan host "$HOSTSSH" +scan tailnet "$TAILNET" +scan tsnet "$TSNET" f_tsnet +scan ip "$CGNAT" f_ip +scan ip "$RFC1918" +scan email "$EMAIL" f_email +scan login "$LOGIN" +scan token "$TOKEN" + +sort -u "$T/hits" | while read -r name loc; do + f="${loc%:*}" + if [ -f "$ALLOW" ] && grep -qE "^$name +$f( |\$)" "$ALLOW"; then continue; fi + echo "private identifier ($name): $loc" +done >"$T/bad" +if [ -s "$T/bad" ]; then + cat "$T/bad" + echo "FAIL: replace with placeholders, or add an explicit line to scripts/private-identifiers.allow" >&2 + exit 1 +fi +echo "ok: no private identifiers in $(wc -l <"$T/files" | tr -d ' ') files" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index dd5655c..e4cd828 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -16,7 +16,7 @@ set -euo pipefail LOGIN="${1:?usage: deploy.sh }" IDENTITY="${2:?usage: deploy.sh }" ROOT=$(cd "$(dirname "$0")/.." && pwd) -BIN="$ROOT/.build/release/oab-instance-mcp" +BIN="$ROOT/.build/release/oab-computer" EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" [ -x "$BIN" ] || { echo "build first: swift build -c release" >&2; exit 1; } @@ -25,7 +25,7 @@ NEWEST_SRC=$(find "$ROOT/Sources" -name '*.swift' -newer "$BIN" | head -1) VERSION=$("$BIN" --version) TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT -APP="$TMP/oab-instance-mcp.app" +APP="$TMP/oab-computer.app" "$ROOT/scripts/assemble-app.sh" "$BIN" "$APP" "$VERSION" @@ -37,7 +37,7 @@ if [ -n "${KEYCHAIN:-}" ]; then KC_ARGS=(--keychain "$KEYCHAIN") fi codesign --force --options runtime --timestamp=none \ - ${KC_ARGS[@]+"${KC_ARGS[@]}"} --sign "$IDENTITY" --identifier dev.openab.instance-mcp "$APP" + ${KC_ARGS[@]+"${KC_ARGS[@]}"} --sign "$IDENTITY" --identifier dev.openab.computer "$APP" codesign --verify --deep --strict "$APP" TEAM=$(codesign -dvv "$APP" 2>&1 | sed -n 's/^TeamIdentifier=//p') diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 812e02f..597ddc4 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -1,20 +1,21 @@ #!/bin/bash # Install the Linux hands node from a release tarball (run ON the target, as the desktop user). # -# curl -fsSL https://github.com/openabdev/instance-mcp/releases/latest/download/oab-instance-mcp-VERSION-linux-$(dpkg --print-architecture).tar.gz | tar xz -# cd oab-instance-mcp-*-linux-* && ./install-linux.sh [--allow-login you@example.com] [--no-browser] [--headless-seat] +# V=; A=$(dpkg --print-architecture) # arm64 | amd64 +# curl -fsSL "https://github.com/openabdev/openab-computer/releases/download/v$V/oab-computer-$V-linux-$A.tar.gz" | tar xz +# cd oab-computer-$V-linux-$A && ./install-linux.sh [--allow-login you@example.com] [--no-browser] [--headless-seat] # # --headless-seat: for a server install with no desktop (Ubuntu Server, a mini PC in a closet): # installs sway and runs it headless (one 1920x1080 virtual output) as user unit oab-seat.service. # Boxes that already run a wlroots desktop (Raspberry Pi OS labwc, sway) do not need it. # -# Installs the binary + units under ~/.local/oab-instance-mcp, generates or keeps the bearer +# Installs the binary + units under ~/.local/oab-computer, generates or keeps the bearer # token, detects the Tailscale login, installs @playwright/mcp if node is present, enables # linger and `tailscale serve --https=8444`. Idempotent. Full walkthrough: docs/linux-setup.md. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -BASE="$HOME/.local/oab-instance-mcp" -CFG="$HOME/.config/oab-instance-mcp" +BASE="$HOME/.local/oab-computer" +CFG="$HOME/.config/oab-computer" UNITS="$HOME/.config/systemd/user" ALLOW_LOGIN=""; BROWSER=1; PORT=8795; SERVE_PORT=8444; HEADLESS_SEAT=0 while [ $# -gt 0 ]; do @@ -32,6 +33,26 @@ need() { command -v "$1" >/dev/null || { echo "missing: $1 (apt-get install $2)" need tailscale tailscale; need grim grim; need wlrctl wlrctl; need wtype wtype; need python3 python3 [ "$(id -u)" != 0 ] || { echo "run as the desktop user, not root" >&2; exit 64; } +# Warn only (read-only: never stops, disables or removes the old units). A 0.11 or earlier node runs +# oab-instance-mcp.service on the same port; it would fight the new oab-computer.service for it. +LEGACY_FOUND=() +for U in oab-instance-mcp.service oab-pw-mcp.service; do + if systemctl --user is-active --quiet "$U" >/dev/null 2>&1 || systemctl --user is-enabled --quiet "$U" >/dev/null 2>&1; then + LEGACY_FOUND+=("$U") + fi +done +if [ "${#LEGACY_FOUND[@]}" -gt 0 ]; then + { + echo "install: WARNING: the old unit(s) ${LEGACY_FOUND[*]} (0.11 or earlier) are still active or enabled." + echo "The old oab-instance-mcp.service listens on port 8795 (the default); if the new oab-computer.service uses the same port ($PORT), they will fight over it;" + echo "the old oab-pw-mcp.service holds the Playwright port (8794) that oab-computer-pw-mcp.service needs." + echo "This installer does not touch them. To stop and remove them, run:" + echo " systemctl --user disable --now ${LEGACY_FOUND[*]}" + echo " rm ${LEGACY_FOUND[*]/#/$UNITS/}" + echo " systemctl --user daemon-reload" + } >&2 +fi + # node installed via fnm/nvm is often a symlink with npm/npx only beside the real binary. NODE_BIN="" if command -v node >/dev/null; then NODE_BIN="$(dirname "$(readlink -f "$(command -v node)")")"; export PATH="$NODE_BIN:$PATH"; fi @@ -41,14 +62,14 @@ if [ "$HEADLESS_SEAT" = 1 ]; then need sway sway mkdir -p "$HOME/.config/sway" [ -f "$HOME/.config/sway/config" ] || cat >"$HOME/.config/sway/config" <<'SWAY' -# Headless seat for the oab-instance-mcp hands node: one virtual 1080p output, no input devices. +# Headless seat for the oab-computer hands node: one virtual 1080p output, no input devices. output HEADLESS-1 resolution 1920x1080 position 0,0 default_border none focus_follows_mouse no SWAY cat >"$UNITS/oab-seat.service" <<'UNIT' [Unit] -Description=Headless sway seat for the oab-instance-mcp hands node +Description=Headless sway seat for the oab-computer hands node [Service] Environment=WLR_BACKENDS=headless @@ -81,7 +102,14 @@ if [ -z "$ALLOW_LOGIN" ]; then [ -n "$ALLOW_LOGIN" ] || { echo "cannot detect Tailscale login; is this node logged in? (or pass --allow-login)" >&2; exit 78; } fi -install -m 755 "$HERE/oab-instance-mcp" "$BASE/oab-instance-mcp" +install -m 755 "$HERE/oab-computer" "$BASE/oab-computer" +# 0.11 and earlier kept the token under ~/.config/oab-instance-mcp. Copy it (source only, the old file +# stays) so existing clients keep working; generate a token only when neither file has one. +OLD_TOKEN="$HOME/.config/oab-instance-mcp/token" +if [ ! -s "$CFG/token" ] && [ -s "$OLD_TOKEN" ]; then + mkdir -p "$CFG"; ( umask 077; cp "$OLD_TOKEN" "$CFG/token" ); chmod 600 "$CFG/token" + echo "copied the bearer token from $OLD_TOKEN to $CFG/token" +fi if [ ! -s "$CFG/token" ]; then head -c 32 /dev/urandom | base64 | tr -d '=+/\n' | head -c 40 > "$CFG/token"; chmod 600 "$CFG/token" echo "generated bearer token at $CFG/token" @@ -125,9 +153,9 @@ JSON sudo -n apt-get install -y -qq fonts-dejavu-core fonts-liberation fonts-noto-cjk fonts-noto-color-emoji >/dev/null 2>&1 || \ echo "note: install fonts (fonts-dejavu-core fonts-noto-cjk) or pages render as boxes" >&2 fi - cat >"$UNITS/oab-pw-mcp.service" <"$UNITS/oab-computer-pw-mcp.service" </dev/null; then sudo mkdir -p /etc/chromium/policies/managed echo '{"DefaultMediaStreamSetting": 2, "DefaultNotificationsSetting": 2, "DefaultGeolocationSetting": 2}' \ - | sudo tee /etc/chromium/policies/managed/oab-instance-mcp.json >/dev/null + | sudo tee /etc/chromium/policies/managed/oab-computer.json >/dev/null else echo "note: could not write Chromium policy (no sudo); camera/mic prompts will need a mouse click" >&2 fi @@ -157,13 +185,13 @@ fi # Unquoted heredoc so $VARS expand: escape any literal backtick or $ in it (#90). # scripts/test-install-linux.sh diffs the written units against their expected text. -cat >"$UNITS/oab-instance-mcp.service" <"$UNITS/oab-computer.service" </dev/null || loginctl enable-linger "$USER" 2>/dev/null || echo "note: enable linger manually: sudo loginctl enable-linger $USER" >&2 if sudo -n true 2>/dev/null; then @@ -192,7 +220,11 @@ else fi sleep 1 -curl -fsS "127.0.0.1:$PORT/healthz" >/dev/null && echo "daemon: ok" +if [ "${#LEGACY_FOUND[@]}" -gt 0 ]; then + echo "daemon: not checked. The old unit(s) ${LEGACY_FOUND[*]} may hold port $PORT; remove them as warned above, then run: systemctl --user restart oab-computer.service" >&2 +else + curl -fsS "127.0.0.1:$PORT/healthz" >/dev/null && echo "daemon: ok" +fi DNS=$(tailscale status --json | python3 -c 'import sys,json;print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))') echo "MCP URL : https://$DNS:$SERVE_PORT/mcp" echo "token : $CFG/token (allow-login: $ALLOW_LOGIN)" diff --git a/scripts/install-prebuilt.sh b/scripts/install-prebuilt.sh index de4f401..3ece71c 100755 --- a/scripts/install-prebuilt.sh +++ b/scripts/install-prebuilt.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Install a PREBUILT, already-signed oab-instance-mcp.app for the logged-in user. +# Install a PREBUILT, already-signed oab-computer.app for the logged-in user. # # This script never signs or modifies the bundle. That is load-bearing: an ad-hoc # re-sign changes the app's designated requirement and silently drops the human's @@ -9,7 +9,7 @@ # Usage: install-prebuilt.sh [--allow-login ] # # Test seams (not used by the package): INSTALL_HOME, ALLOW_UNSIGNED=1, -# SKIP_LAUNCH=1, SKIP_TAILSCALE=1. +# SKIP_LAUNCH=1, SKIP_TAILSCALE=1, LEGACY_LAUNCHCTL (a stub launchctl for the old-agent warning). set -euo pipefail SOURCE="${1:?usage: install-prebuilt.sh [--allow-login ]}" @@ -27,22 +27,22 @@ while [ "$#" -gt 0 ]; do esac done -LABEL=dev.openab.instance-mcp +LABEL=dev.openab.computer OLD_LABEL=dev.openab.mac-agent -BUNDLE_ID=dev.openab.instance-mcp +BUNDLE_ID=dev.openab.computer EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" HOME_DIR="${INSTALL_HOME:-$HOME}" -BASE="$HOME_DIR/.local/oab-instance-mcp" -APP="$BASE/oab-instance-mcp.app" +BASE="$HOME_DIR/.local/oab-computer" +APP="$BASE/oab-computer.app" TOKEN_FILE="$HOME_DIR/.config/oab-computer/token" LEGACY_TOKEN_FILE="$HOME_DIR/.config/oab-instance-mcp/token" OLDEST_TOKEN_FILE="$HOME_DIR/.config/oab-mac-agent/token" PLIST="$HOME_DIR/Library/LaunchAgents/$LABEL.plist" -LOG_DIR="$HOME_DIR/Library/Logs/oab-instance-mcp" +LOG_DIR="$HOME_DIR/Library/Logs/oab-computer" UID_=$(id -u) case "$SOURCE" in *.app) ;; *) echo "installer: source must be an .app bundle" >&2; exit 64 ;; esac -[ -x "$SOURCE/Contents/MacOS/oab-instance-mcp" ] || { +[ -x "$SOURCE/Contents/MacOS/oab-computer" ] || { echo "installer: app has no executable: $SOURCE" >&2; exit 66 } [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$SOURCE/Contents/Info.plist" 2>/dev/null)" = "$BUNDLE_ID" ] || { @@ -62,6 +62,33 @@ if [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then } fi +# Warn only (read-only: never stops, removes or changes the old agent). A 0.11 or earlier agent is +# named dev.openab.instance-mcp; it listens on the same port 8795 and would fight this one. +# Test seam: LEGACY_LAUNCHCTL replaces /bin/launchctl for this check (and enables it under SKIP_LAUNCH=1). +LEGACY_LABEL=dev.openab.instance-mcp +LEGACY_FOUND=() +for L in "$LEGACY_LABEL" "$LEGACY_LABEL.pw-mcp"; do + if [ -n "${LEGACY_LAUNCHCTL:-}" ] || [ "${SKIP_LAUNCH:-0}" != "1" ]; then + if "${LEGACY_LAUNCHCTL:-/bin/launchctl}" print "gui/$UID_/$L" >/dev/null 2>&1; then + LEGACY_FOUND+=("$L"); continue + fi + fi + if [ -f "$HOME_DIR/Library/LaunchAgents/$L.plist" ]; then LEGACY_FOUND+=("$L"); fi +done +if [ "${#LEGACY_FOUND[@]}" -gt 0 ]; then + { + echo "installer: WARNING: the old LaunchAgent ${LEGACY_FOUND[*]} (0.11 or earlier) is still installed." + echo "The old main agent listens on port 8795 (the default; the new agent uses $PORT) and the old Playwright agent on 8794; they will fight the new agents for those ports;" + echo "until it is removed, /healthz may be answered by the old agent, so a healthy check does not prove the new agent is bound." + echo "This installer does not touch it. To stop and remove it, run:" + for L in "${LEGACY_FOUND[@]}"; do + echo " launchctl bootout gui/$UID_/$L" + echo " rm \"$HOME_DIR/Library/LaunchAgents/$L.plist\"" + done + echo "Leftovers this installer also does not touch: the old payload under \"/Library/Application Support/OpenAB/instance-mcp\" and the old Keychain items (service dev.openab.instance-mcp.grant)." + } >&2 +fi + TS="${TAILSCALE_CLI:-}" if [ "${SKIP_TAILSCALE:-0}" != "1" ]; then if [ -z "$TS" ]; then @@ -170,8 +197,8 @@ rm -f "$HOME_DIR/Library/LaunchAgents/$OLD_LABEL.plist" # Copy to a sibling and verify there, then replace atomically. `ditto` preserves # the Developer ID signature and notarization ticket; we never run codesign here. -STAGED="$BASE/.oab-instance-mcp.app.installing.$$" -BACKUP="$BASE/.oab-instance-mcp.app.previous.$$" +STAGED="$BASE/.oab-computer.app.installing.$$" +BACKUP="$BASE/.oab-computer.app.previous.$$" rm -rf "$STAGED" "$BACKUP" /usr/bin/ditto "$SOURCE" "$STAGED" if [ "${ALLOW_UNSIGNED:-0}" != "1" ]; then @@ -189,14 +216,14 @@ PB=/usr/libexec/PlistBuddy "$PB" -c "Add :Label string $LABEL" "$PLIST" "$PB" -c 'Add :ProgramArguments array' "$PLIST" ARGS=( - "$APP/Contents/MacOS/oab-instance-mcp" + "$APP/Contents/MacOS/oab-computer" --port "$PORT" --allow-login "$LOGIN" --token-file "$TOKEN_FILE" --menu-bar --public-url "https://$DNSNAME:$HTTPS_PORT/mcp" ) -if [ "${SKIP_LAUNCH:-0}" != "1" ] && /bin/launchctl print "gui/$UID_/dev.openab.instance-mcp.pw-mcp" >/dev/null 2>&1; then +if [ "${SKIP_LAUNCH:-0}" != "1" ] && /bin/launchctl print "gui/$UID_/dev.openab.computer.pw-mcp" >/dev/null 2>&1; then ARGS+=(--upstream browser=http://127.0.0.1:8794/mcp) elif [ "${TEST_WITH_UPSTREAM:-0}" = "1" ]; then ARGS+=(--upstream browser=http://127.0.0.1:8794/mcp) @@ -206,7 +233,7 @@ fi # switchboard.profile (observe|owner) optionally picks the profile. The retired # `desktop` (#59) is written as `observe`, never `owner`, with a warning; the # file itself is left as it is. -SB_DIR="$HOME_DIR/.config/oab-instance-mcp" +SB_DIR="$HOME_DIR/.config/oab-computer" if [ -s "$SB_DIR/switchboard.url" ] && [ -s "$SB_DIR/switchboard.secret" ]; then SB_URL=$(/usr/bin/head -n 1 "$SB_DIR/switchboard.url" | /usr/bin/tr -d '[:space:]') ARGS+=(--switchboard "$SB_URL" --switchboard-secret-file "$SB_DIR/switchboard.secret") @@ -259,7 +286,7 @@ if [ -n "$OLD_REQ" ] && [ -n "$NEW_REQ" ] && [ "$OLD_REQ" != "$NEW_REQ" ]; then echo "Future Developer-ID releases keep this requirement stable." fi -echo "installed oab-instance-mcp $VERSION" +echo "installed oab-computer $VERSION" echo " app: $APP" echo " login: $LOGIN" echo " MCP: https://$DNSNAME:$HTTPS_PORT/mcp" diff --git a/scripts/package-pkg.sh b/scripts/package-pkg.sh index bfd7df3..dbbe89f 100755 --- a/scripts/package-pkg.sh +++ b/scripts/package-pkg.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build a macOS installer package around an ALREADY-SIGNED app. # -# Usage: package-pkg.sh [version] +# Usage: package-pkg.sh [version] # Env: PKG_SIGN_IDENTITY="Developer ID Installer: ..." (optional for smoke; # required for release) # EXPECT_TEAM=6LPQNY95AQ @@ -13,7 +13,7 @@ OUT="${2:?usage: package-pkg.sh [version]}" VERSION="${3:-}" ROOT=$(cd "$(dirname "$0")/.." && pwd) EXPECT_TEAM="${EXPECT_TEAM:-6LPQNY95AQ}" -IDENTIFIER="${PKG_IDENTIFIER:-dev.openab.instance-mcp.installer}" +IDENTIFIER="${PKG_IDENTIFIER:-dev.openab.computer.installer}" [ -d "$APP" ] || { echo "package-pkg: app not found: $APP" >&2; exit 66; } case "$OUT" in *.pkg) ;; *) echo "package-pkg: output must end in .pkg" >&2; exit 64 ;; esac @@ -35,8 +35,8 @@ trap 'rm -rf "$TMP"' EXIT PAYLOAD="$TMP/payload" SCRIPTS="$TMP/scripts" COMPONENTS="$TMP/components.plist" -mkdir -p "$PAYLOAD/Library/Application Support/OpenAB/instance-mcp" "$SCRIPTS" "$(dirname "$OUT")" -/usr/bin/ditto "$APP" "$PAYLOAD/Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app" +mkdir -p "$PAYLOAD/Library/Application Support/OpenAB/computer" "$SCRIPTS" "$(dirname "$OUT")" +/usr/bin/ditto "$APP" "$PAYLOAD/Library/Application Support/OpenAB/computer/oab-computer.app" cp "$ROOT/scripts/install-prebuilt.sh" "$SCRIPTS/install-prebuilt.sh" cp "$ROOT/scripts/pkg/postinstall" "$SCRIPTS/postinstall" chmod 755 "$SCRIPTS/install-prebuilt.sh" "$SCRIPTS/postinstall" @@ -68,7 +68,7 @@ rm -f "$OUT" /usr/bin/pkgbuild "${ARGS[@]}" "$OUT" /usr/sbin/pkgutil --payload-files "$OUT" | /usr/bin/grep -q \ - '^\./Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app/Contents/MacOS/oab-instance-mcp$' + '^\./Library/Application Support/OpenAB/computer/oab-computer.app/Contents/MacOS/oab-computer$' if [ -n "${PKG_SIGN_IDENTITY:-}" ]; then /usr/sbin/pkgutil --check-signature "$OUT" | /usr/bin/grep -q 'Developer ID Installer' fi diff --git a/scripts/pkg/postinstall b/scripts/pkg/postinstall index 31a9214..18b4e3e 100755 --- a/scripts/pkg/postinstall +++ b/scripts/pkg/postinstall @@ -7,14 +7,14 @@ set -euo pipefail CONSOLE_USER=$(/usr/bin/stat -f '%Su' /dev/console) case "$CONSOLE_USER" in ''|root|loginwindow|_mbsetupuser) - echo "oab-instance-mcp: no logged-in desktop user; sign in, then run the package again" >&2 + echo "oab-computer: no logged-in desktop user; sign in, then run the package again" >&2 exit 1 ;; esac UID_=$(/usr/bin/id -u "$CONSOLE_USER") HOME_DIR=$(/usr/bin/dscl . -read "/Users/$CONSOLE_USER" NFSHomeDirectory | \ /usr/bin/sed 's/^NFSHomeDirectory: //') -[ -d "$HOME_DIR" ] || { echo "oab-instance-mcp: home not found for $CONSOLE_USER" >&2; exit 1; } +[ -d "$HOME_DIR" ] || { echo "oab-computer: home not found for $CONSOLE_USER" >&2; exit 1; } GID_=$(/usr/bin/id -g "$CONSOLE_USER") # Repair only our own per-user paths before dropping root. v0.6.0's relocatable @@ -23,18 +23,18 @@ GID_=$(/usr/bin/id -g "$CONSOLE_USER") # This also makes a partially interrupted package retry idempotent. Do not chown # a parent directory owned by the user, and never touch unrelated files. for owned in \ - "$HOME_DIR/.local/oab-instance-mcp" \ - "$HOME_DIR/.config/oab-instance-mcp" \ - "$HOME_DIR/Library/Logs/oab-instance-mcp" \ - "$HOME_DIR/Library/LaunchAgents/dev.openab.instance-mcp.plist"; do + "$HOME_DIR/.local/oab-computer" \ + "$HOME_DIR/.config/oab-computer" \ + "$HOME_DIR/Library/Logs/oab-computer" \ + "$HOME_DIR/Library/LaunchAgents/dev.openab.computer.plist"; do if [ -e "$owned" ]; then /usr/sbin/chown -R "$UID_:$GID_" "$owned"; fi done SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -SOURCE="/Library/Application Support/OpenAB/instance-mcp/oab-instance-mcp.app" +SOURCE="/Library/Application Support/OpenAB/computer/oab-computer.app" HELPER="$SCRIPT_DIR/install-prebuilt.sh" -[ -d "$SOURCE" ] || { echo "oab-instance-mcp: package payload app is missing" >&2; exit 1; } -[ -x "$HELPER" ] || { echo "oab-instance-mcp: package installer helper is missing" >&2; exit 1; } +[ -d "$SOURCE" ] || { echo "oab-computer: package payload app is missing" >&2; exit 1; } +[ -x "$HELPER" ] || { echo "oab-computer: package installer helper is missing" >&2; exit 1; } # `asuser` places launchctl/Tailscale in the GUI bootstrap namespace; sudo drops # filesystem/process identity. The helper itself never needs root. diff --git a/scripts/private-identifiers.allow b/scripts/private-identifiers.allow new file mode 100644 index 0000000..dfd5394 --- /dev/null +++ b/scripts/private-identifiers.allow @@ -0,0 +1,8 @@ +# Intentional exceptions for scripts/check-no-private-identifiers.sh. Format: # reason +# Patterns: host tailnet tsnet ip email login token. Keep this list short. +login .mailmap # git author mapping: maps the old commit email to the GitHub noreply identity +email .mailmap # same +host README.md # the word "black" in "an idle Mac returns black screenshots" (display sleep), not a host +host docs/operators.md # same: "solid black" screenshot +host poc/reverse-attach-linux/src/platform/linux/seat.rs # Rust test variable named black (a blank output) +host poc/reverse-attach-linux/src/jobs/tests.rs # Rust test variable p1 (first poll) diff --git a/scripts/release-version.sh b/scripts/release-version.sh index 19b769e..a0e2d7f 100755 --- a/scripts/release-version.sh +++ b/scripts/release-version.sh @@ -6,7 +6,7 @@ # RELEASE_VERSION_SOURCE overrides the source file (tests only). set -euo pipefail -SRC="${RELEASE_VERSION_SOURCE:-$(cd "$(dirname "$0")/.." && pwd)/Sources/oab-instance-mcp/main.swift}" +SRC="${RELEASE_VERSION_SOURCE:-$(cd "$(dirname "$0")/.." && pwd)/Sources/oab-computer/main.swift}" [ -f "$SRC" ] || { echo "release-version: $SRC not found" >&2; exit 66; } N=$(grep -c '^let version = "[^"]*"$' "$SRC" || true) [ "$N" = 1 ] || { echo "release-version: expected one 'let version = \"…\"' line in $SRC, found $N" >&2; exit 65; } diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 5d6033e..34a621d 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,8 +1,8 @@ #!/bin/bash -# Loopback smoke test for oab-instance-mcp. Every step is time-bounded; the server it +# Loopback smoke test for oab-computer. Every step is time-bounded; the server it # starts is killed on exit. Usage: scripts/smoke.sh [path-to-binary] [port] set -u -B="${1:-.build/release/oab-instance-mcp}" +B="${1:-.build/release/oab-computer}" PORT="${2:-8797}" URL="http://127.0.0.1:$PORT/mcp" LOG="$(mktemp)" @@ -15,7 +15,7 @@ post() { # post [extra curl args...] } field() { python3 -c 'import json,sys; r=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; } -pkill -f "oab-instance-mcp --port $PORT" 2>/dev/null || true +pkill -f "oab-computer --port $PORT" 2>/dev/null || true echo "--- no auth flags (expect refusal, exit 64) ---" t 5 "$B" --port "$PORT"; echo "exit=$?" diff --git a/scripts/test-check-no-private-identifiers.sh b/scripts/test-check-no-private-identifiers.sh new file mode 100755 index 0000000..d10354b --- /dev/null +++ b/scripts/test-check-no-private-identifiers.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# The private-identifier check passes on this repo and fails on a temp copy with a forbidden pattern. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT +rc=0 +"$ROOT/scripts/check-no-private-identifiers.sh" "$ROOT" || { echo "FAIL: repo has private identifiers"; rc=1; } +mkdir -p "$T/scripts" +cp "$ROOT/scripts/check-no-private-identifiers.sh" "$ROOT/scripts/private-identifiers.allow" "$T/scripts/" +# Each sample is built from pieces so this file itself holds no forbidden literal. +declare -a SAMPLES=( + "see host mac""mini for details" + "wss://box.tail""b836bb.ts.net/x" + "wss://box.tailabc123.ts.net/x" + "connect to 100.74.""35.49 now" + "lan 192.168.""0.25" + "contact pahud""net@gmail.com" + "key tskey-auth-""abcdef123456" +) +for s in "${SAMPLES[@]}"; do + rm -rf "$T/repo"; mkdir -p "$T/repo/scripts" "$T/repo/docs" + cp "$T/scripts/"* "$T/repo/scripts/" + printf '%s\n' "$s" >"$T/repo/docs/x.md" + if NO_GIT=1 "$T/repo/scripts/check-no-private-identifiers.sh" "$T/repo" >/dev/null 2>&1; then + echo "FAIL: not detected: $s"; rc=1 + else echo "ok: detected: ${s%% *}..."; fi +done +rm -rf "$T/repo"; mkdir -p "$T/repo/scripts" "$T/repo/docs"; cp "$T/scripts/"* "$T/repo/scripts/" +printf '%s\n' "wss://..ts.net 100.64.0.1 192.0.2.5 you@example.com" >"$T/repo/docs/x.md" +if NO_GIT=1 "$T/repo/scripts/check-no-private-identifiers.sh" "$T/repo" >/dev/null 2>&1; then echo "ok: placeholders pass"; else echo "FAIL: placeholders flagged"; rc=1; fi +[ "$rc" = 0 ] && echo "check-no-private-identifiers: all checks passed" +exit "$rc" diff --git a/scripts/test-install-linux.sh b/scripts/test-install-linux.sh index b62e238..a9db6a5 100755 --- a/scripts/test-install-linux.sh +++ b/scripts/test-install-linux.sh @@ -20,8 +20,8 @@ fail() { echo "FAIL: $*" >&2; FAIL=1; } PKG="$T/pkg"; mkdir -p "$PKG" cp "$HERE/install-linux.sh" "$PKG/" cp "$HERE/../poc/reverse-attach-linux/pw-mcp.sh" "$PKG/" -printf '#!/bin/sh\nexit 0\n' >"$PKG/oab-instance-mcp" -chmod 755 "$PKG/install-linux.sh" "$PKG/pw-mcp.sh" "$PKG/oab-instance-mcp" +printf '#!/bin/sh\nexit 0\n' >"$PKG/oab-computer" +chmod 755 "$PKG/install-linux.sh" "$PKG/pw-mcp.sh" "$PKG/oab-computer" STUBS="$T/stubs"; mkdir -p "$STUBS" LOG="$T/calls.log" @@ -29,7 +29,9 @@ stub() { # stub NAME [BODY]: logs "NAME args" to $LOG, then runs BODY (default: printf '#!/bin/bash\necho "%s $*" >>"%s"\n%s\n' "$1" "$LOG" "${2:-exit 0}" >"$STUBS/$1" chmod 755 "$STUBS/$1" } -for c in grim wlrctl wtype sway systemctl loginctl curl node npm npx chromium; do stub "$c"; done +for c in grim wlrctl wtype sway loginctl curl node npm npx chromium; do stub "$c"; done +# systemctl: the is-active / is-enabled queries answer "no" (no old unit present); everything else succeeds. +stub systemctl 'case "$2" in is-active|is-enabled) exit 1;; esac; exit 0' stub sudo 'exit 1' # no passwordless sudo: the installer takes its no-sudo paths stub fc-list 'echo "DejaVuSans.ttf: DejaVu Sans:style=Book"' stub tailscale '[ "$1 $2" = "status --json" ] && echo '"'"'{"Self":{"UserID":7,"DNSName":"node.example.ts.net."},"User":{"7":{"LoginName":"ci-user"}}}'"'"' @@ -42,9 +44,9 @@ stub ls 'case "$1" in /run/user/*) printf "wayland-0\nwayland-1\n";; *) exec '"$ run() { local name="$1"; shift local home="$T/$name/home" - mkdir -p "$home/.local/oab-instance-mcp/pw-mcp/node_modules/.bin" - printf '#!/bin/sh\n' >"$home/.local/oab-instance-mcp/pw-mcp/node_modules/.bin/playwright-mcp" - chmod 755 "$home/.local/oab-instance-mcp/pw-mcp/node_modules/.bin/playwright-mcp" + mkdir -p "$home/.local/oab-computer/pw-mcp/node_modules/.bin" + printf '#!/bin/sh\n' >"$home/.local/oab-computer/pw-mcp/node_modules/.bin/playwright-mcp" + chmod 755 "$home/.local/oab-computer/pw-mcp/node_modules/.bin/playwright-mcp" : >"$LOG" # stdin is /dev/null: a command substitution such as `bash` in a heredoc must not wait on it. if ! (cd "$PKG" && HOME="$home" USER="${USER:-$(id -un)}" PATH="$STUBS:$PATH" ./install-linux.sh "$@") "$T/$name/out" 2>&1; then @@ -56,7 +58,7 @@ run() { # expect NAME UNIT LOGIN PORT: diff the written unit against the expected text below. expect() { local name="$1" unit="$2" login="$3" port="$4" home="$T/$1/home" - local base="$home/.local/oab-instance-mcp" cfg="$home/.config/oab-instance-mcp" + local base="$home/.local/oab-computer" cfg="$home/.config/oab-computer" local got="$home/.config/systemd/user/$unit" [ -f "$got" ] || { fail "$name: $unit not written"; return; } sed -e "s|@BASE@|$base|g" -e "s|@CFG@|$cfg|g" -e "s|@LOGIN@|$login|g" -e "s|@PORT@|$port|g" \ @@ -66,13 +68,13 @@ expect() { # Expected units. @UPSTREAM@ becomes the browser line or an empty line (the installer writes # an empty $UPSTREAM line with --no-browser). -cat >"$T/expected-oab-instance-mcp.service.in" <<'EOF' +cat >"$T/expected-oab-computer.service.in" <<'EOF' [Unit] -Description=oab-instance-mcp (Linux hands node: /mcp + /attach) +Description=oab-computer (Linux hands node: /mcp + /attach) After=graphical-session.target [Service] -ExecStart=@BASE@/oab-instance-mcp +ExecStart=@BASE@/oab-computer Environment=BIND=127.0.0.1:@PORT@ Environment=MCP_TOKEN_FILE=@CFG@/token Environment=MCP_ALLOW_LOGIN=@LOGIN@ @@ -87,9 +89,9 @@ KillMode=process [Install] WantedBy=default.target EOF -cat >"$T/expected-oab-pw-mcp.service" <<'EOF' +cat >"$T/expected-oab-computer-pw-mcp.service" <<'EOF' [Unit] -Description=Playwright MCP for the oab-instance-mcp hands node (loopback :8794) +Description=Playwright MCP for the oab-computer hands node (loopback :8794) After=graphical-session.target oab-seat.service [Service] @@ -107,7 +109,7 @@ WantedBy=default.target EOF cat >"$T/expected-oab-seat.service" <<'EOF' [Unit] -Description=Headless sway seat for the oab-instance-mcp hands node +Description=Headless sway seat for the oab-computer hands node [Service] Environment=WLR_BACKENDS=headless @@ -126,31 +128,31 @@ EOF # 1. Defaults plus a headless seat: browser unit, login detected from `tailscale status`. run browser --headless-seat sed 's|@UPSTREAM@|Environment=MCP_UPSTREAM=browser=http://127.0.0.1:8794/mcp|' \ - "$T/expected-oab-instance-mcp.service.in" >"$T/expected-oab-instance-mcp.service" -expect browser oab-instance-mcp.service ci-user 8795 -expect browser oab-pw-mcp.service ci-user 8795 + "$T/expected-oab-computer.service.in" >"$T/expected-oab-computer.service" +expect browser oab-computer.service ci-user 8795 +expect browser oab-computer-pw-mcp.service ci-user 8795 expect browser oab-seat.service ci-user 8795 for want in "systemctl --user enable --now oab-seat.service" \ - "systemctl --user enable --now oab-pw-mcp.service" \ - "systemctl --user enable --now oab-instance-mcp.service" \ - "systemctl --user restart oab-instance-mcp.service" \ + "systemctl --user enable --now oab-computer-pw-mcp.service" \ + "systemctl --user enable --now oab-computer.service" \ + "systemctl --user restart oab-computer.service" \ "loginctl enable-linger"; do grep -qF "$want" "$T/browser/calls" || fail "browser: no call: $want" done # 2. --no-browser, explicit login and port: no browser unit, empty upstream line. run nobrowser --no-browser --allow-login ci-other --port 9000 -sed 's|@UPSTREAM@||' "$T/expected-oab-instance-mcp.service.in" >"$T/expected-oab-instance-mcp.service" -expect nobrowser oab-instance-mcp.service ci-other 9000 -for u in oab-pw-mcp.service oab-seat.service; do +sed 's|@UPSTREAM@||' "$T/expected-oab-computer.service.in" >"$T/expected-oab-computer.service" +expect nobrowser oab-computer.service ci-other 9000 +for u in oab-computer-pw-mcp.service oab-seat.service; do [ ! -e "$T/nobrowser/home/.config/systemd/user/$u" ] || fail "nobrowser: $u written" done -if grep -qF "oab-pw-mcp" "$T/nobrowser/calls"; then fail "nobrowser: oab-pw-mcp.service enabled"; fi +if grep -qF "oab-computer-pw-mcp" "$T/nobrowser/calls"; then fail "nobrowser: oab-computer-pw-mcp.service enabled"; fi for name in browser nobrowser; do - unit="$T/$name/home/.config/systemd/user/oab-instance-mcp.service" + unit="$T/$name/home/.config/systemd/user/oab-computer.service" grep -qF '# Apps opened via `bash` belong to the desktop' "$unit" || fail "$name: KillMode comment mangled" - tok="$T/$name/home/.config/oab-instance-mcp/token" + tok="$T/$name/home/.config/oab-computer/token" if [ ! -s "$tok" ] || [ "$(stat -c %a "$tok")" != 600 ]; then fail "$name: token missing or not mode 600"; fi done @@ -158,8 +160,8 @@ done # installed here, and verify rejects a missing executable. if command -v systemd-analyze >/dev/null; then for name in browser nobrowser; do - units=("$T/$name/home/.config/systemd/user/oab-instance-mcp.service") - [ "$name" = nobrowser ] || units+=("$T/$name/home/.config/systemd/user/oab-pw-mcp.service") + units=("$T/$name/home/.config/systemd/user/oab-computer.service") + [ "$name" = nobrowser ] || units+=("$T/$name/home/.config/systemd/user/oab-computer-pw-mcp.service") if ! out="$(HOME="$T/$name/home" systemd-analyze --user verify "${units[@]}" 2>&1)" || [ -n "$out" ]; then echo "$out" >&2; fail "$name: systemd-analyze verify" fi diff --git a/scripts/test-legacy-agent.sh b/scripts/test-legacy-agent.sh new file mode 100755 index 0000000..4646825 --- /dev/null +++ b/scripts/test-legacy-agent.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# The installers only WARN about a 0.11-or-earlier agent (macOS LaunchAgent dev.openab.instance-mcp, +# Linux unit oab-instance-mcp.service). They must print the commands to remove it and must never run +# them. This test drives both installers against stub launchctl / systemctl that log every call and +# fail any call other than a read-only query, in a fake HOME. It touches no real launchd, systemd, +# Tailscale or user directory. +# +# scripts/test-legacy-agent.sh +# +# macOS cases run only where /usr/libexec/PlistBuddy exists; Linux cases run wherever bash does. +# shellcheck disable=SC2016 # stub bodies are literal on purpose +# shellcheck disable=SC2015 # `A && ok || fail`: ok() always succeeds, so this is an if/else +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +FAIL=0 +fail() { echo "FAIL: $*" >&2; FAIL=1; } +ok() { echo "ok: $*"; } +has() { grep -qF -- "$2" "$1"; } +has_line() { grep -qxF -- "$2" "$1"; } + +OLD_LABEL=dev.openab.instance-mcp +OLD_PW_LABEL=dev.openab.instance-mcp.pw-mcp +OLD_SENTINEL='OLD-AGENT-FILE-MUST-STAY-BYTE-IDENTICAL' + +# ---------------------------------------------------------------- macOS +if [ -x /usr/libexec/PlistBuddy ]; then + UID_=$(id -u) + printf '#!/bin/sh\necho 0.0.0\n' >"$T/fakebin"; chmod 755 "$T/fakebin" + APP="$T/oab-computer.app" + "$ROOT/scripts/assemble-app.sh" "$T/fakebin" "$APP" 0.0.0 >/dev/null || { echo "assemble-app failed" >&2; exit 1; } + # The stub answers `print gui//