Add the configure app

This commit is contained in:
2026-10-26 15:16:53 -06:00
parent 63d56da089
commit bbd6b83f64
21 changed files with 1010 additions and 1 deletions
+38
View File
@@ -202,6 +202,44 @@ Use "sibling list" to see what's installed and "sibling remove {name}" to uninst
To make your own repository work as a sibling project, see [skills/sibling-project-format.md](skills/sibling-project-format.md). To make your own repository work as a sibling project, see [skills/sibling-project-format.md](skills/sibling-project-format.md).
## Configure
Some of what CoreDesktop installs needs you to sign in or make a choice before you can use it. To see what's set up and set up the rest, run in the terminal:
```
configure
```
It covers:
- **Your Federated Computer Core.** Give it your Superpanel's address (e.g. https://superpanel.example.com), your Core email, name and password, and it adds Superpanel to your applications, joins this computer to your Core's Tailscale network, adds your Nextcloud (calendar, contacts and files) and email to GNOME's Online Accounts, and mounts your Nextcloud files at /mnt/example.com ("example.com" in the Files sidebar). For Nextcloud and the files it creates a Nextcloud app password, which you can revoke in Nextcloud under Settings > Security.
- Signing in to Claude Code, opencode and Hermes Agent and choosing their model providers.
To set up one thing, name it, e.g. `configure hermes`; `configure --list` shows the names.
## Sandboxed AI agents
CoreDesktop installs the AI coding agents opencode, Hermes Agent and Claude Code, and runs them in a sandbox. In the sandbox, an agent:
- can change files only in the directory you start it from (plus its own settings and a few package caches), so start it from a project directory
- can't read your SSH keys, password stores, browser profiles, cloud and Git credentials, or the other agents' logins
- can reach only AI providers and common developer sites: GitHub, Gitea, npm, PyPI, crates.io, RubyGems, the Go module proxy and archlinux.org
Typing `opencode`, `hermes` or `hermes-agent` runs it in the sandbox. To run one without it, type `unsandboxed opencode`. Hermes' gateway, the background service that lets you talk to Hermes through Matrix and other messaging apps, runs in the sandbox too, and can reach the Matrix server in Hermes' settings. For other messaging services, add their domains as shown below. Claude Code uses its own built-in sandbox for every command it runs (see `/sandbox` inside Claude).
Before using an agent the first time, sign in and choose a model provider with `configure` (see below).
If an agent needs a site or folder the sandbox blocks, add it in `~/.config/coredesktop/sandbox/base.json` (for every agent) or `~/.config/coredesktop/sandbox/opencode.json` (for one agent):
```json
{
"network": { "allowedDomains": ["docs.example.com"] },
"filesystem": { "allowWrite": ["~/notes"] }
}
```
The sandbox is Anthropic's [sandbox runtime](https://github.com/anthropics/sandbox-runtime), which uses bubblewrap and socat. The default rules are in [sandbox/config/](sandbox/config/).
## mDNS Networking ## mDNS Networking
If you want to use mDNS networking on your LAN (the ability to log in to other local machines using the format "machine.local"), then you should replace /etc/nsswitch.conf with the one in this repository and add /etc/mdns.allow. These two files are formatted to avoid any conflicts with systemd-resolved that may cause nss-mdns not to work. See: https://wiki.archlinux.org/title/Avahi#Hostname_resolution (section 4.2) for more information. If you want to use mDNS networking on your LAN (the ability to log in to other local machines using the format "machine.local"), then you should replace /etc/nsswitch.conf with the one in this repository and add /etc/mdns.allow. These two files are formatted to avoid any conflicts with systemd-resolved that may cause nss-mdns not to work. See: https://wiki.archlinux.org/title/Avahi#Hostname_resolution (section 4.2) for more information.
Vendored Executable
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
#
# Purpose : Set up the things CoreDesktop installs that need your input, such
# as connecting to your Federated Computer Core and signing in to
# the AI agents.
#
# Each file in configure.d/ is one item. It sets TITLE and DESCRIPTION and
# defines is_configured and run_configure. It can also define is_installed;
# the item is left out when that fails. To add an item, add a file there.
# Wrapped in { ...; exit; } so bash reads the whole script up front; update
# replaces this file while it runs.
{
COREDESKTOP="$HOME/.local/share/coredesktop"
PATH="$HOME/.local/bin:$PATH"
# Run an agent's real command rather than its sandboxed one in sandbox/bin:
# setting an agent up writes outside its sandbox
unsandboxed() {
"$COREDESKTOP/sandbox/bin/unsandboxed" "$@"
}
# Ask a yes/no question; the default is no
confirm() {
local answer
read -rp "$1 [y/N] " answer
[[ "$answer" == [yY]* ]]
}
# Ask a yes/no question; the default is yes
confirm_yes() {
local answer
read -rp "$1 [Y/n] " answer
[[ "$answer" != [nN]* ]]
}
usage() {
cat << EOF
Usage: configure [item]
configure Show what's set up and choose what to set up
configure <item> Set up one item
configure --list Show what's set up, and the item names
EOF
}
# configure.d/30-claude.sh -> claude
item_name() {
local name
name="$(basename "$1" .sh)"
echo "${name#[0-9][0-9]-}"
}
# Prints "<state>|<title>|<description>"; state is done, todo or absent
item_info() {
(
source "$1"
if declare -f is_installed > /dev/null && ! is_installed &> /dev/null; then
echo "absent||"
elif is_configured &> /dev/null; then
echo "done|$TITLE|$DESCRIPTION"
else
echo "todo|$TITLE|$DESCRIPTION"
fi
)
}
configure_item() {
(
source "$1"
echo
echo -e "\e[1m$TITLE\e[0m"
run_configure
)
local status=$?
if [ $status -ne 0 ]; then
echo -e "\e[31m$(item_name "$1") was not set up.\e[0m"
fi
return $status
}
# Fill ITEMS, STATES, TITLES and DESCRIPTIONS with the installed items
load_items() {
local file state title description
ITEMS=() STATES=() TITLES=() DESCRIPTIONS=()
for file in "$COREDESKTOP"/configure.d/*.sh; do
[ -f "$file" ] || continue
IFS='|' read -r state title description < <(item_info "$file")
[ "$state" = absent ] && continue
ITEMS+=("$file") STATES+=("$state") TITLES+=("$title") DESCRIPTIONS+=("$description")
done
}
show_items() {
local i mark
for i in "${!ITEMS[@]}"; do
if [ "${STATES[$i]}" = done ]; then
mark="\e[32mdone\e[0m "
else
mark="\e[33mto do\e[0m"
fi
printf " %2d) $mark %-22s %s\n" $((i + 1)) "${TITLES[$i]}" "${DESCRIPTIONS[$i]}"
done
}
case "$1" in
"") ;;
-h|--help)
usage
exit 0 ;;
-l|--list)
load_items
for i in "${!ITEMS[@]}"; do
printf '%-6s %-14s %s\n' "${STATES[$i]/todo/to do}" "$(item_name "${ITEMS[$i]}")" "${TITLES[$i]}"
done
exit 0 ;;
*)
for file in "$COREDESKTOP"/configure.d/*.sh; do
if [ "$(item_name "$file")" = "$1" ]; then
configure_item "$file"
exit
fi
done
echo "No item named $1. These are: configure --list"
exit 2 ;;
esac
while true; do
load_items
echo
echo "CoreDesktop configuration"
echo
show_items
echo
echo " a) Set up everything still to do"
echo " q) Quit"
echo
read -rp "Choose: " choice || exit 0
case "$choice" in
q|Q|"")
exit 0 ;;
a|A)
for i in "${!ITEMS[@]}"; do
[ "${STATES[$i]}" = todo ] && configure_item "${ITEMS[$i]}"
done ;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ITEMS[@]} )); then
configure_item "${ITEMS[$((choice - 1))]}"
else
echo "Choose a number from the list, a or q."
fi ;;
esac
done
}
+315
View File
@@ -0,0 +1,315 @@
#!/bin/bash
TITLE="Federated Computer Core"
DESCRIPTION="Superpanel, Tailscale, Nextcloud, email and files"
# What you entered last time, so running this again only does what's missing
CORE_CONF="$HOME/.config/coredesktop/core.conf"
GOA_ACCOUNT="$COREDESKTOP/configure.d/lib/goa-account"
[ -f "$CORE_CONF" ] && source "$CORE_CONF"
# CORE_DOMAIN, CORE_EMAIL and CORE_NAME come from core.conf; the rest follow
core_names() {
CORE_USER="${CORE_EMAIL%@*}"
NEXTCLOUD="https://nextcloud.$CORE_DOMAIN"
WEBDAV="$NEXTCLOUD/remote.php/dav/files/$CORE_USER"
MOUNT="/mnt/$CORE_DOMAIN"
SUPERPANEL_DESKTOP="$HOME/.local/share/applications/core-$CORE_DOMAIN-superpanel.desktop"
}
superpanel_done() {
[ -f "$SUPERPANEL_DESKTOP" ]
}
# Tailscale has no status field naming its login server, so core.conf
# records that it was set up for this Core
tailscale_done() {
[ "$CORE_TAILSCALE" = "$CORE_DOMAIN" ] \
&& tailscale status --json 2> /dev/null | jq -e '.BackendState == "Running"' > /dev/null
}
nextcloud_done() {
"$GOA_ACCOUNT" exists owncloud "$CORE_USER@nextcloud.$CORE_DOMAIN" 2> /dev/null
}
email_done() {
"$GOA_ACCOUNT" exists imap_smtp "$CORE_EMAIL" 2> /dev/null
}
files_done() {
grep -qs " $MOUNT davfs " /etc/fstab && grep -qs "^$MOUNT " "$HOME/.davfs2/secrets"
}
is_configured() {
[ -n "$CORE_DOMAIN" ] || return 1
core_names
superpanel_done && tailscale_done && nextcloud_done && email_done && files_done
}
save_conf() {
mkdir -p "$(dirname "$CORE_CONF")"
{
printf 'CORE_DOMAIN=%q\n' "$CORE_DOMAIN"
printf 'CORE_EMAIL=%q\n' "$CORE_EMAIL"
printf 'CORE_NAME=%q\n' "$CORE_NAME"
printf 'CORE_TAILSCALE=%q\n' "$CORE_TAILSCALE"
} > "$CORE_CONF"
}
# Basic auth header for curl, read from stdin (-H @-) so the password isn't
# in the process list
auth_header() {
printf 'Authorization: Basic %s\n' "$(printf '%s:%s' "$1" "$2" | base64 -w0)"
}
# Create a Nextcloud app password for the Online Accounts account and the
# files mount, so they don't keep your Core password. It shows in Nextcloud
# under Settings > Security as "CoreDesktop on <this computer>", where it can
# be revoked. Returns 2 if Nextcloud says the password is wrong.
nextcloud_app_password() {
local response code
response="$(auth_header "$CORE_USER" "$PASSWORD" | curl -s -w '\n%{http_code}' -H @- \
-H 'OCS-APIRequest: true' -A "CoreDesktop on $HOSTNAME" \
"$NEXTCLOUD/ocs/v2.php/core/getapppassword?format=json")"
code="${response##*$'\n'}"
case "$code" in
200) APP_PASSWORD="$(jq -r '.ocs.data.apppassword // empty' <<< "${response%$'\n'*}")"
[ -n "$APP_PASSWORD" ] ;;
401) return 2 ;;
*) return 1 ;;
esac
}
desktop_entry() {
local file="$1" name="$2" comment="$3" url="$4" icon="$5"
mkdir -p "$(dirname "$file")"
cat > "$file" << EOF
[Desktop Entry]
Type=Application
Name=$name
Comment=$comment
Exec=xdg-open $url
Icon=$icon
Categories=Network;
EOF
}
setup_superpanel() {
local url="https://superpanel.$CORE_DOMAIN"
local icon="$HOME/.local/share/icons/core-$CORE_DOMAIN-superpanel.png"
# The login page carries Superpanel's icon inline
mkdir -p "$(dirname "$icon")"
if ! curl -s "$url/" | grep -o 'rel="icon"[^>]*base64,[^"]*' | head -n 1 \
| sed 's/.*base64,//' | base64 -d > "$icon" 2> /dev/null || [ ! -s "$icon" ]; then
rm -f "$icon"
icon="applications-internet"
fi
desktop_entry "$SUPERPANEL_DESKTOP" "Superpanel" "Manage your Federated Computer Core ($CORE_DOMAIN)" "$url" "$icon"
echo "Added Superpanel to your applications."
}
setup_tailscale() {
# --reset and --force-reauth move the machine from any other tailnet;
# --operator lets you, and the Tailscale GNOME extension, use it without sudo
echo "Open the link below to sign in to your Core's Tailscale network."
sudo tailscale up --reset --force-reauth --login-server "https://headscale.$CORE_DOMAIN" \
--accept-routes --operator="$USER" || return 1
CORE_TAILSCALE="$CORE_DOMAIN"
save_conf
}
setup_nextcloud() {
[ -n "$APP_PASSWORD" ] || { echo "This needs your Core password, and Nextcloud didn't accept it."; return 1; }
"$GOA_ACCOUNT" add << EOF || return 1
$(jq -n --arg user "$CORE_USER" --arg host "nextcloud.$CORE_DOMAIN" --arg url "$NEXTCLOUD" \
--arg password "$APP_PASSWORD" '{
provider: "owncloud",
identity: $user,
presentation_identity: "\($user)@\($host)",
credentials: {password: $password},
details: {
Uri: "\($url)/remote.php/webdav",
FilesEnabled: "true",
CalendarEnabled: "true",
CalDavUri: "\($url)/remote.php/dav",
ContactsEnabled: "true",
CardDavUri: "\($url)/remote.php/dav",
AcceptSslErrors: "false"
}}')
EOF
echo "Added Nextcloud to Online Accounts (calendar, contacts and files)."
}
# The mail server publishes its settings (Thunderbird's autoconfig format).
# Prints: imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username
mail_settings() {
curl -sf --max-time 15 \
"https://autoconfig.$CORE_DOMAIN/mail/config-v1.1.xml?emailaddress=$CORE_EMAIL" \
| python3 -I -c '
import sys, xml.etree.ElementTree as ET
provider = ET.parse(sys.stdin).getroot().find("emailProvider")
imap = provider.find("incomingServer[@type=\"imap\"]")
smtp = provider.find("outgoingServer[@type=\"smtp\"]")
print(*(s.findtext(f) for s in (imap, smtp) for f in ("hostname", "port", "socketType")),
imap.findtext("username"))'
}
# Online Accounts takes host:port, and only the host for the usual port
host_port() {
if [ "$2" = "$3" ]; then echo "$1"; else echo "$1:$2"; fi
}
setup_email() {
[ -n "$PASSWORD" ] || { echo "This needs your Core password."; return 1; }
local imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username
if ! read -r imap_host imap_port imap_socket smtp_host smtp_port smtp_socket username < <(mail_settings) \
|| [ -z "$smtp_socket" ]; then
echo "The mail server didn't give its settings; using mail.$CORE_DOMAIN with the usual ports."
imap_host="mail.$CORE_DOMAIN" imap_port=993 imap_socket=SSL
smtp_host="mail.$CORE_DOMAIN" smtp_port=587 smtp_socket=STARTTLS
username="%EMAILADDRESS%"
fi
case "$username" in
%EMAILLOCALPART%) username="$CORE_USER" ;;
*) username="$CORE_EMAIL" ;;
esac
[ "$imap_socket" = SSL ] && imap_std=993 || imap_std=143
[ "$smtp_socket" = SSL ] && smtp_std=465 || smtp_std=587
"$GOA_ACCOUNT" add << EOF || return 1
$(jq -n --arg email "$CORE_EMAIL" --arg name "$CORE_NAME" --arg user "$username" \
--arg password "$PASSWORD" \
--arg imap "$(host_port "$imap_host" "$imap_port" "$imap_std")" --arg imap_socket "$imap_socket" \
--arg smtp "$(host_port "$smtp_host" "$smtp_port" "$smtp_std")" --arg smtp_socket "$smtp_socket" '{
provider: "imap_smtp",
identity: $email,
presentation_identity: $email,
credentials: {"imap-password": $password, "smtp-password": $password},
details: {
Enabled: "true",
EmailAddress: $email,
Name: $name,
ImapHost: $imap,
ImapUserName: $user,
ImapUseSsl: (if $imap_socket == "SSL" then "true" else "false" end),
ImapUseTls: (if $imap_socket == "STARTTLS" then "true" else "false" end),
ImapAcceptSslErrors: "false",
SmtpHost: $smtp,
SmtpUseAuth: "true",
SmtpUserName: $user,
SmtpAuthLogin: "false",
SmtpAuthPlain: "true",
SmtpUseSsl: (if $smtp_socket == "SSL" then "true" else "false" end),
SmtpUseTls: (if $smtp_socket == "STARTTLS" then "true" else "false" end),
SmtpAcceptSslErrors: "false"
}}')
EOF
echo "Added $CORE_EMAIL to Online Accounts."
}
# Mount your Nextcloud files at /mnt/<domain> with davfs2. You can mount it
# without sudo once it's in /etc/fstab with "user" and you're in Arch's davfs2
# group (network). It's mounted now and each time you log in.
setup_files() {
[ -n "$APP_PASSWORD" ] || { echo "This needs your Core password, and Nextcloud didn't accept it."; return 1; }
local secret
sudo install -d -o "$USER" -g "$(id -gn)" "$MOUNT" || return 1
if ! grep -qs " $MOUNT davfs " /etc/fstab; then
echo "$WEBDAV $MOUNT davfs rw,user,noauto,uid=$USER,_netdev 0 0" | sudo tee -a /etc/fstab > /dev/null || return 1
fi
if ! id -nG "$USER" | grep -qw network; then
sudo usermod -aG network "$USER" || return 1
fi
# davfs2 only reads this file if no one else can
mkdir -p -m 700 "$HOME/.davfs2"
touch "$HOME/.davfs2/secrets"
chmod 600 "$HOME/.davfs2/secrets"
secret="${APP_PASSWORD//\\/\\\\}"
secret="${secret//\"/\\\"}"
sed -i "\\#^$MOUNT #d" "$HOME/.davfs2/secrets"
printf '%s %s "%s"\n' "$MOUNT" "$CORE_USER" "$secret" >> "$HOME/.davfs2/secrets"
mkdir -p "$HOME/.config/autostart"
cat > "$HOME/.config/autostart/core-$CORE_DOMAIN-files.desktop" << EOF
[Desktop Entry]
Type=Application
Name=Nextcloud files ($CORE_DOMAIN)
Comment=Mount your Nextcloud files at $MOUNT
Exec=sh -c 'mountpoint -q $MOUNT || mount $MOUNT'
X-GNOME-Autostart-enabled=true
NoDisplay=true
EOF
# Shown in the Files sidebar as the domain
local bookmarks="$HOME/.config/gtk-3.0/bookmarks"
mkdir -p "$(dirname "$bookmarks")"
grep -qs "^file://$MOUNT " "$bookmarks" || echo "file://$MOUNT $CORE_DOMAIN" >> "$bookmarks"
# sg picks up the network group now, without logging out and in again
if ! mountpoint -q "$MOUNT"; then
sg network -c "mount $MOUNT" || return 1
fi
echo "Your Nextcloud files are at $MOUNT (\"$CORE_DOMAIN\" in the Files sidebar)."
}
# Run a step unless it's done; a failed step doesn't stop the ones after it
step() {
local name="$1" title="$2"
if "${name}_done"; then
echo " $title: already set up"
return 0
fi
if ! confirm_yes "Set up $title?"; then
return 0
fi
"setup_$name" || { echo -e "\e[31m$title was not set up.\e[0m"; STATUS=1; }
}
run_configure() {
local url
STATUS=0
read -erp "Your Superpanel's address (e.g. https://superpanel.example.com): " \
-i "${CORE_DOMAIN:+https://superpanel.$CORE_DOMAIN}" url
url="${url#*://}"
url="${url%%/*}"
CORE_DOMAIN="${url#superpanel.}"
if [ -z "$CORE_DOMAIN" ] || ! curl -s -o /dev/null --max-time 15 "https://superpanel.$CORE_DOMAIN/"; then
echo "Couldn't reach https://superpanel.$CORE_DOMAIN."
return 1
fi
read -erp "Your Core email: " -i "${CORE_EMAIL:-$USER@$CORE_DOMAIN}" CORE_EMAIL
read -erp "Your name, as shown on email you send: " \
-i "${CORE_NAME:-$(getent passwd "$USER" | cut -d: -f5 | cut -d, -f1)}" CORE_NAME
[[ "$CORE_EMAIL" == *@* ]] || { echo "That isn't an email address."; return 1; }
core_names
save_conf
# Nextcloud, email and files need your Core password; Nextcloud checks it
if ! nextcloud_done || ! email_done || ! files_done; then
local tries
for tries in 1 2 3; do
read -rsp "Your Core password: " PASSWORD
echo
nextcloud_app_password
case $? in
0) break ;;
2) echo "Nextcloud says that password is wrong."; PASSWORD="" ;;
*) echo "Couldn't reach Nextcloud at $NEXTCLOUD; trying the password for email anyway."; break ;;
esac
done
fi
echo
step superpanel "Superpanel in your applications"
step tailscale "Tailscale with your Core (headscale.$CORE_DOMAIN)"
step nextcloud "Nextcloud in Online Accounts"
step email "Email in Online Accounts"
step files "Nextcloud files at $MOUNT"
unset PASSWORD APP_PASSWORD
return $STATUS
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
TITLE="Claude Code"
DESCRIPTION="Sign in to Anthropic"
is_installed() {
command -v claude
}
is_configured() {
claude auth status
}
run_configure() {
echo " 1) Claude subscription (Pro, Max, Team or Enterprise)"
echo " 2) Anthropic Console (pay for API usage)"
local choice
read -rp "How do you pay for Claude? [1] " choice
case "$choice" in
""|1) claude auth login ;;
2) claude auth login --console ;;
*) echo "Choose 1 or 2."; return 1 ;;
esac
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
TITLE="opencode"
DESCRIPTION="Sign in to AI providers"
is_installed() {
[ -x /usr/bin/opencode ]
}
# Signed in to at least one provider
is_configured() {
jq -e 'length > 0' "$HOME/.local/share/opencode/auth.json"
}
run_configure() {
echo "Choose a provider and sign in or paste its API key. You can pick the model inside opencode with /models."
unsandboxed opencode auth login || return 1
while confirm "Sign in to another provider?"; do
unsandboxed opencode auth login || return 1
done
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
TITLE="Hermes Agent"
DESCRIPTION="Choose a model provider and tools"
is_installed() {
[ -x "$HOME/.local/bin/hermes" ]
}
# Signed in with OAuth (e.g. Nous Portal), or an API key is set
is_configured() {
[ -s "$HOME/.hermes/auth.json" ] || grep -qE '^[A-Z_]*API_KEY=.+' "$HOME/.hermes/.env"
}
run_configure() {
echo " 1) Nous Portal (one sign-in covers the model, web search, images and more)"
echo " 2) Another provider (OpenRouter, OpenAI, Anthropic or your own endpoint)"
local choice
read -rp "Which provider? [1] " choice
case "$choice" in
""|1) unsandboxed hermes setup --portal ;;
2) unsandboxed hermes setup ;;
*) echo "Choose 1 or 2."; return 1 ;;
esac
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Add an account to GNOME Online Accounts, the way Settings does.
Settings adds accounts by calling AddAccount on the Online Accounts service,
which saves the account and keeps its password in the GNOME keyring. This
calls the same method. The account comes on stdin rather than as arguments,
so its password doesn't show up in the process list.
Usage:
goa-account exists <provider> <presentation-identity>
goa-account add < account.json
account.json:
{"provider": "owncloud", "identity": "...", "presentation_identity": "...",
"credentials": {"password": "..."}, "details": {"Uri": "...", ...}}
"""
import json
import sys
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
BUS_NAME = "org.gnome.OnlineAccounts"
def call(bus, path, interface, method, parameters, reply_type):
return bus.call_sync(BUS_NAME, path, interface, method, parameters,
GLib.VariantType(reply_type), Gio.DBusCallFlags.NONE,
-1, None).unpack()
def accounts(bus):
(objects,) = call(bus, "/org/gnome/OnlineAccounts",
"org.freedesktop.DBus.ObjectManager", "GetManagedObjects",
None, "(a{oa{sa{sv}}})")
for interfaces in objects.values():
account = interfaces.get("org.gnome.OnlineAccounts.Account")
if account:
yield account
def exists(bus, provider, presentation_identity):
return any(account.get("ProviderType") == provider
and account.get("PresentationIdentity") == presentation_identity
for account in accounts(bus))
def add(bus, account):
credentials = {key: GLib.Variant("s", value)
for key, value in account["credentials"].items()}
parameters = GLib.Variant("(sssa{sv}a{ss})", (
account["provider"], account["identity"],
account["presentation_identity"], credentials, account["details"]))
call(bus, "/org/gnome/OnlineAccounts/Manager",
"org.gnome.OnlineAccounts.Manager", "AddAccount", parameters, "(o)")
def main():
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if len(sys.argv) == 4 and sys.argv[1] == "exists":
return 0 if exists(bus, sys.argv[2], sys.argv[3]) else 1
if len(sys.argv) == 2 and sys.argv[1] == "add":
account = json.load(sys.stdin)
if exists(bus, account["provider"], account["presentation_identity"]):
return 0
add(bus, account)
return 0
print(__doc__.strip().split("\n\n")[2], file=sys.stderr)
return 2
if __name__ == "__main__":
try:
sys.exit(main())
except GLib.Error as error:
print(f"GNOME Online Accounts: {error.message}", file=sys.stderr)
sys.exit(1)
+8 -1
View File
@@ -53,12 +53,13 @@ cd ~/.local/share/coredesktop
# Source utility functions (provides install_packages and other helpers) # Source utility functions (provides install_packages and other helpers)
source utils.sh source utils.sh
# Install the refresh, update, sibling and version scripts to the user's bin # Install the refresh, update, sibling, version and configure scripts to the user's bin
mkdir -p ~/.local/bin mkdir -p ~/.local/bin
install_script ~/.local/share/coredesktop/refresh ~/.local/bin/refresh install_script ~/.local/share/coredesktop/refresh ~/.local/bin/refresh
install_script ~/.local/share/coredesktop/update ~/.local/bin/update install_script ~/.local/share/coredesktop/update ~/.local/bin/update
install_script ~/.local/share/coredesktop/sibling ~/.local/bin/sibling install_script ~/.local/share/coredesktop/sibling ~/.local/bin/sibling
install_script ~/.local/share/coredesktop/version ~/.local/bin/version install_script ~/.local/share/coredesktop/version ~/.local/bin/version
install_script ~/.local/share/coredesktop/configure ~/.local/bin/configure
# Source the package list (defines SYSTEM_UTILS, DEV_TOOLS, DESKTOP, etc. arrays) # Source the package list (defines SYSTEM_UTILS, DEV_TOOLS, DESKTOP, etc. arrays)
if [ ! -f "packages.conf" ]; then if [ ! -f "packages.conf" ]; then
@@ -150,6 +151,10 @@ echo "Installing Kickstart Neovim..."
echo "Installing Claude Code..." echo "Installing Claude Code..."
bash claude.sh bash claude.sh
# Run AI agents (opencode, hermes) in a sandbox, and turn on Claude Code's own
echo "Setting up sandboxed AI agents..."
bash ~/.local/share/coredesktop/sandbox/setup.sh
# Install Migrate Backup System # Install Migrate Backup System
curl -L -o migrate https://github.com/CyphrRiot/Migrate/raw/main/bin/migrate curl -L -o migrate https://github.com/CyphrRiot/Migrate/raw/main/bin/migrate
chmod +x migrate chmod +x migrate
@@ -218,3 +223,5 @@ if ! ls /usr/lib/modules/$(uname -r) > /dev/null 2>&1; then
else else
echo "Kernel is up to date, no reboot needed. You may want to consider a reboot if other significant software was updated or installed." echo "Kernel is up to date, no reboot needed. You may want to consider a reboot if other significant software was updated or installed."
fi fi
echo "To connect to your Federated Computer Core and sign in to the AI agents, run: configure"
+3
View File
@@ -4,6 +4,7 @@ SYSTEM_UTILS=(
bash-completion bash-completion
bluez bluez
bluez-utils bluez-utils
bubblewrap
borg borg
btop btop
cadaver cadaver
@@ -79,11 +80,13 @@ DEV_TOOLS=(
neovim neovim
nodejs nodejs
npm npm
opencode
pandoc pandoc
python-docutils python-docutils
python-myst-parser python-myst-parser
python-pillow python-pillow
python-pip python-pip
python-gobject
python-pipx python-pipx
python-pygments python-pygments
rust rust
Executable
+119
View File
@@ -0,0 +1,119 @@
#!/bin/bash
#
# Purpose : Run an AI agent inside Anthropic's sandbox runtime (srt). srt uses
# bubblewrap to limit which files the agent can read and write, and
# removes its network; socat links it to a proxy outside the sandbox
# that only lets through the domains in its config.
#
# sandbox/bin/<agent> links here and shell/bashrc puts sandbox/bin first on
# PATH, so typing an agent's name runs it sandboxed. `unsandboxed <agent>` runs
# the agent's real command instead.
#
# The config is sandbox/config/base.json, then sandbox/config/<agent>.json,
# then your own ~/.config/coredesktop/sandbox/base.json and <agent>.json, in
# the same format. Later files add to the lists in earlier ones. Domains in
# COREDESKTOP_SANDBOX_DOMAINS (space-separated) are allowed too; setup.sh uses
# it for Hermes' gateway, whose messaging server differs on each machine.
# Wrapped in { ...; exit; } so bash reads the whole script up front; update
# replaces this file (git pull) while agents are running.
{
SANDBOX="$HOME/.local/share/coredesktop/sandbox"
USER_CONFIG="$HOME/.config/coredesktop/sandbox"
name="$(basename "$0")"
# The agent's real command: the first one on PATH outside sandbox/bin
real_command() {
local dir bin_dir IFS=:
bin_dir="$(realpath "$SANDBOX/bin")"
for dir in $PATH; do
[ -n "$dir" ] && [ "$(realpath "$dir" 2> /dev/null)" != "$bin_dir" ] || continue
if [ -x "$dir/$1" ] && [ ! -d "$dir/$1" ]; then
echo "$dir/$1"
return 0
fi
done
return 1
}
if [ "$name" = "unsandboxed" ]; then
if [ $# -eq 0 ]; then
echo "Usage: unsandboxed <agent> [arguments]"
exit 2
fi
agent="$1"
shift
real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; }
exec "$real" "$@"
fi
agent="$name"
real="$(real_command "$agent")" || { echo "$agent isn't installed."; exit 127; }
# Commands that share another agent's sandbox config
case "$agent" in
hermes-agent) profile=hermes ;;
*) profile="$agent" ;;
esac
srt="$(command -v srt || echo "$HOME/.local/bin/srt")"
if [ ! -x "$srt" ]; then
echo "The sandbox (srt) isn't installed; run update. To run $agent without it: unsandboxed $agent"
exit 1
fi
# The agent can change anything in the directory it starts in
case "$PWD" in
"$HOME"|/)
echo "Start $agent from a project directory. In the sandbox it can change anything in the directory it starts in, and this is $PWD."
exit 1 ;;
esac
configs=("$SANDBOX/config/base.json")
[ -f "$SANDBOX/config/$profile.json" ] && configs+=("$SANDBOX/config/$profile.json")
ours=("${configs[@]}")
for file in "$USER_CONFIG/base.json" "$USER_CONFIG/$profile.json"; do
[ -f "$file" ] && configs+=("$file")
done
# Objects are merged and lists joined, so later files only add to earlier ones
MERGE='
def merge(a; b):
reduce (b | to_entries[]) as $e (a;
.[$e.key] |= (
if type == "object" and ($e.value | type) == "object" then merge(.; $e.value)
elif type == "array" and ($e.value | type) == "array" then . + $e.value | unique
else $e.value end));
reduce .[] as $config ({}; merge(.; $config))'
# srt runs in place of this script (exec below), so a stop signal, e.g. from
# systemd, reaches it and it passes it on to the agent. That leaves nothing to
# remove the settings file afterwards, so it's named after this process, and
# files whose process has ended are removed here.
SETTINGS_DIR="${XDG_RUNTIME_DIR:-/tmp}/coredesktop-sandbox"
mkdir -p -m 700 "$SETTINGS_DIR" || exit 1
for old in "$SETTINGS_DIR"/*.json; do
pid="${old%.json}"
pid="${pid##*.}"
[[ "$pid" =~ ^[0-9]+$ ]] && ! kill -0 "$pid" 2> /dev/null && rm -f "$old"
done
# Outside the sandbox's writable paths, so the agent can't change its own rules
settings="$SETTINGS_DIR/$agent.$$.json"
if ! jq -s --arg domains "$COREDESKTOP_SANDBOX_DOMAINS" \
"$MERGE"' | .network.allowedDomains += ($domains | split(" ") | map(select(. != "")))' \
"${configs[@]}" > "$settings"; then
echo "Couldn't read the sandbox config: ${configs[*]}"
exit 1
fi
# Bubblewrap can only make writable a directory that exists, and the agent
# can't create one itself in a parent it may not write to
while IFS= read -r path; do
path="${path/#\~/$HOME}"
case "$path" in "$HOME"/*) mkdir -p "$path" ;; esac
done < <(jq -rs "$MERGE | .filesystem.allowWrite[]? | strings" "${ours[@]}")
exec "$srt" --settings "$settings" -- "$real" "$@"
}
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+1
View File
@@ -0,0 +1 @@
../agent
+82
View File
@@ -0,0 +1,82 @@
{
"network": {
"allowedDomains": [
"anthropic.com",
"*.anthropic.com",
"openai.com",
"*.openai.com",
"chatgpt.com",
"*.chatgpt.com",
"openrouter.ai",
"*.openrouter.ai",
"nousresearch.com",
"*.nousresearch.com",
"generativelanguage.googleapis.com",
"api.mistral.ai",
"api.deepseek.com",
"api.x.ai",
"api.groq.com",
"api.together.xyz",
"*.githubcopilot.com",
"github.com",
"*.github.com",
"*.githubusercontent.com",
"gitea.federated.computer",
"registry.npmjs.org",
"registry.yarnpkg.com",
"pypi.org",
"files.pythonhosted.org",
"crates.io",
"*.crates.io",
"rubygems.org",
"*.rubygems.org",
"proxy.golang.org",
"sum.golang.org",
"archlinux.org",
"*.archlinux.org"
],
"deniedDomains": []
},
"filesystem": {
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.password-store",
"~/.local/share/keyrings",
"~/.netrc",
"~/.git-credentials",
"~/.aws",
"~/.azure",
"~/.config/gcloud",
"~/.kube",
"~/.docker",
"~/.config/gh",
"~/.config/tea",
"~/.config/borg",
"~/.config/Bitwarden",
"~/.config/Element",
"~/.mozilla",
"~/.config/chromium",
"~/.config/google-chrome",
"~/.config/BraveSoftware",
"~/.config/net.imput.helium",
"~/.claude",
"~/.claude.json",
"~/.hermes",
"~/.config/opencode",
"~/.local/share/opencode"
],
"allowRead": [],
"allowWrite": [
".",
"/tmp",
"~/.npm",
"~/.cache/pip",
"~/.cargo/registry",
"~/.cargo/git",
"~/go/pkg/mod"
],
"denyWrite": []
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"network": {
"allowedDomains": []
},
"filesystem": {
"allowRead": [
"~/.hermes"
],
"allowWrite": [
"~/.hermes"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"network": {
"allowedDomains": [
"opencode.ai",
"*.opencode.ai",
"models.dev"
]
},
"filesystem": {
"allowRead": [
"~/.config/opencode",
"~/.local/share/opencode"
],
"allowWrite": [
"~/.config/opencode",
"~/.local/share/opencode",
"~/.local/state/opencode",
"~/.cache/opencode"
]
}
}
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# Purpose : Set up sandboxed AI agents (see sandbox/agent): install or update
# Anthropic's sandbox runtime (srt) and Hermes Agent, and turn on
# Claude Code's built-in sandbox. Run by install.sh and on every
# update (updates.d/72-sandbox.sh).
COREDESKTOP="$HOME/.local/share/coredesktop"
STATUS=0
# npm's global prefix is under /usr, so install to ~/.local; srt lands in ~/.local/bin
echo "Installing the sandbox runtime (srt)..."
npm install --global --prefix "$HOME/.local" --no-fund --no-audit @anthropic-ai/sandbox-runtime || STATUS=1
# --non-interactive skips Hermes' setup questions and its background gateway
# service; `configure hermes` chooses a model provider afterwards
if [ ! -x "$HOME/.local/bin/hermes" ]; then
echo "Installing Hermes Agent..."
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive || STATUS=1
fi
# Claude Code runs every command it starts in its own sandbox, also built on
# bubblewrap and socat; it can't run inside srt because bubblewrap can't be
# nested. Anything already in the sandbox section of your settings wins over
# these defaults.
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
if [ -f "$CLAUDE_SETTINGS" ]; then
domains="$(jq '.network.allowedDomains' "$COREDESKTOP/sandbox/config/base.json")"
tmp="$(mktemp "$CLAUDE_SETTINGS.XXXXXX")"
if jq --argjson domains "$domains" \
'.sandbox = ({enabled: true, network: {allowedDomains: $domains}} * (.sandbox // {}))' \
"$CLAUDE_SETTINGS" > "$tmp"; then
if cmp -s "$tmp" "$CLAUDE_SETTINGS"; then
rm -f "$tmp"
else
echo "Turning on Claude Code's sandbox..."
mv -f "$tmp" "$CLAUDE_SETTINGS"
fi
else
echo "Couldn't read $CLAUDE_SETTINGS; Claude Code's sandbox was not turned on."
rm -f "$tmp"
STATUS=1
fi
fi
# Hermes' gateway acts on messages from outside this machine, so it runs in
# the sandbox too: a systemd drop-in starts it through sandbox/bin/hermes. It
# also needs to reach the Matrix server in Hermes' settings, which differs on
# each machine; for other messaging services, add their domains to
# ~/.config/coredesktop/sandbox/hermes.json.
GATEWAY_UNIT="$HOME/.config/systemd/user/hermes-gateway.service"
if [ -f "$GATEWAY_UNIT" ]; then
if [ -x "$HOME/.local/bin/srt" ]; then
homeserver="$(sed -nE "s#^MATRIX_HOMESERVER=[\"']?https?://([^/:\"']+).*#\1#p" "$HOME/.hermes/.env" 2> /dev/null | head -n 1)"
dropin="$GATEWAY_UNIT.d/coredesktop-sandbox.conf"
tmp="$(mktemp)"
cat > "$tmp" << EOF
# Written by CoreDesktop (sandbox/setup.sh): run Hermes' gateway in the sandbox
[Service]
Environment="COREDESKTOP_SANDBOX_DOMAINS=$homeserver"
ExecStart=
ExecStart=%h/.local/share/coredesktop/sandbox/bin/hermes gateway run
EOF
if cmp -s "$tmp" "$dropin"; then
rm -f "$tmp"
else
echo "Running Hermes' gateway in the sandbox..."
mkdir -p "$(dirname "$dropin")" && mv -f "$tmp" "$dropin" && chmod 644 "$dropin" \
&& systemctl --user daemon-reload \
&& systemctl --user try-restart hermes-gateway.service || STATUS=1
fi
else
echo "Hermes' gateway isn't sandboxed yet: the sandbox (srt) isn't installed."
STATUS=1
fi
fi
exit $STATUS
+5
View File
@@ -6,6 +6,11 @@
COREDESKTOP_SHELL="$HOME/.local/share/coredesktop/shell" COREDESKTOP_SHELL="$HOME/.local/share/coredesktop/shell"
# AI agents such as opencode and hermes run sandboxed (see sandbox/agent), so
# sandbox/bin must come before the directories holding their real commands
SANDBOX_BIN="$HOME/.local/share/coredesktop/sandbox/bin"
PATH="$SANDBOX_BIN:${PATH//$SANDBOX_BIN:/}"
# History: keep a lot of it, skip duplicates, and don't let shells overwrite # History: keep a lot of it, skip duplicates, and don't let shells overwrite
# each other's history on exit # each other's history on exit
shopt -s histappend shopt -s histappend
+1
View File
@@ -43,6 +43,7 @@ install_script "$COREDESKTOP/update" ~/.local/bin/update
install_script "$COREDESKTOP/refresh" ~/.local/bin/refresh install_script "$COREDESKTOP/refresh" ~/.local/bin/refresh
install_script "$COREDESKTOP/sibling" ~/.local/bin/sibling install_script "$COREDESKTOP/sibling" ~/.local/bin/sibling
install_script "$COREDESKTOP/version" ~/.local/bin/version install_script "$COREDESKTOP/version" ~/.local/bin/version
install_script "$COREDESKTOP/configure" ~/.local/bin/configure
if [[ -z "$UPDATE_REEXECED" ]]; then if [[ -z "$UPDATE_REEXECED" ]]; then
export UPDATE_REEXECED=1 export UPDATE_REEXECED=1
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
COREDESKTOP="$HOME/.local/share/coredesktop"
run_update() {
local status=0
bash "$COREDESKTOP/sandbox/setup.sh" || status=1
# The real command, not the sandboxed one in sandbox/bin: updating Hermes
# writes outside what its sandbox allows
if [ -x "$HOME/.local/bin/hermes" ]; then
echo "Updating Hermes Agent..."
"$HOME/.local/bin/hermes" update < /dev/null || status=1
fi
return $status
}