Skip to content

Commit 9ff3dfd

Browse files
loan-talvatclaude
andcommitted
Release 0.1: notarized Mac app, published relay image
- The Mac app is signed with a Developer ID and notarized by Apple: `pnpm desktop:notarize` sends the app and the disk image, staples them and checks them with Gatekeeper (CONTRIBUTING.md, "Releasing the desktop app"). - The relay runs from the published image, ghcr.io/synapsr/agentos-relay, in one line; the docs and the website say so. - README, SECURITY.md and the website no longer say there are no signed builds, and say the app is for Macs with Apple silicon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 13454ec commit 9ff3dfd

15 files changed

Lines changed: 270 additions & 67 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ down in [DESIGN.md](docs/DESIGN.md).
5353
`-demo <scene>`, `-demo gallery` included, like the interface's `?demo`.
5454
- New screenshots and a new OS-mode recording in `docs/screenshots`, all
5555
taken from the demos.
56+
- **A notarized desktop app.** With a Developer ID, `pnpm desktop:bundle`
57+
signs the app and everything it carries, and `pnpm desktop:notarize` has
58+
Apple notarize the app and its disk image and staples both tickets.
59+
[CONTRIBUTING](CONTRIBUTING.md#releasing-the-desktop-app) has the steps.
5660

5761
## [0.1.0] - not released yet
5862

‎CONTRIBUTING.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,51 @@ change. CI fails when they are out of date. After changing the presence's
131131
model, export its reference frames for the Swift port again:
132132
`node apps/ios/Scripts/export-presence-reference.mjs`.
133133

134+
## Releasing the desktop app
135+
136+
A release is built on a Mac with an Apple Developer account. Once, before
137+
the first one:
138+
139+
- Have a **Developer ID Application** certificate in your login keychain
140+
(Xcode → Settings → Accounts → Manage Certificates).
141+
`security find-identity -v -p codesigning` prints its full name.
142+
- Save what the notary service needs in a keychain profile. The command
143+
asks for your Apple ID, your team ID and an app-specific password (or
144+
takes an App Store Connect API key), and keeps them in the keychain:
145+
146+
```sh
147+
xcrun notarytool store-credentials <profile>
148+
```
149+
150+
Then, for each release, from the repository:
151+
152+
```sh
153+
export APPLE_SIGNING_IDENTITY="Developer ID Application: <Your Name> (<TEAMID>)"
154+
pnpm desktop:bundle
155+
AGENTOS_NOTARY_PROFILE=<profile> pnpm desktop:notarize
156+
```
157+
158+
`pnpm desktop:bundle` signs every program the app carries with that
159+
identity, with the hardened runtime and a secure timestamp (Node.js,
160+
agent-desktop and its helper), then the app and the disk image. Claude Code
161+
keeps Anthropic's own signature: it ships exactly as published. What each
162+
program is allowed, and why, is written in the entitlements files in
163+
`apps/desktop/src-tauri/macos`. None of them turns off library validation:
164+
nothing in the app loads a library that is not Apple's or its own.
165+
166+
`pnpm desktop:notarize` sends the app, then the disk image, to Apple (a few
167+
minutes each), staples both tickets so that they open offline too, checks
168+
them as Gatekeeper does, and writes the image's checksum next to it:
169+
170+
```
171+
apps/desktop/src-tauri/target/release/bundle/dmg/AgentOS_<version>_<arch>.dmg
172+
apps/desktop/src-tauri/target/release/bundle/dmg/AgentOS_<version>_<arch>.dmg.sha256
173+
```
174+
175+
If Apple refuses a submission, the script prints Apple's log, which names
176+
the file at fault. Anyone can check a download with
177+
`shasum -a 256 -c AgentOS_<version>_<arch>.dmg.sha256`.
178+
134179
## Commits and pull requests
135180

136181
- **Open an issue first** for anything larger than a fix. It saves you from

‎README.fr.md‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,8 @@ documentation détaillée).
137137
[les versions publiées sur GitHub](https://github.com/Synapsr/AgentOS/releases/latest),
138138
ouvrez-la, et répondez à quelques questions : votre prénom, un nom pour
139139
votre agent, ce qu'il peut faire sans demander, et une clé Claude. C'est là
140-
que mène le bouton *Télécharger pour Mac* du site. Pour être clair : la
141-
première version n'est pas encore publiée, cette page est donc vide pour
142-
l'instant.
140+
que mène le bouton *Télécharger pour Mac* du site. Elle est faite pour les
141+
Mac à puce Apple (M1 ou plus récent), signée et notarisée par Apple.
143142

144143
**Pour les développeurs**, et pour qui le veut dès aujourd'hui : compilez-le
145144
depuis les sources, avec le [démarrage rapide](#démarrage-rapide) ci-dessous.
@@ -221,13 +220,11 @@ Trois façons d'y arriver, au choix :
221220
| **Votre propre relais** | Un petit conteneur sur un serveur à vous | C'est votre ordinateur qui s'y connecte : rien ne s'ouvre sur votre réseau |
222221

223222
Un relais est privé : il ne démarre pas sans un jeton, que votre ordinateur
224-
doit présenter. Tant qu'aucune image n'est publiée, construisez-la d'abord
225-
depuis votre clone
226-
(`docker build -f packages/relay/Dockerfile -t agentos-relay .`), puis :
223+
doit présenter. Une ligne, sur n'importe quel serveur avec Docker :
227224

228225
```sh
229226
RELAY_TOKEN=$(openssl rand -hex 32)
230-
docker run -d -p 8080:8080 -v agentos-relay:/data -e RELAY_TOKEN="$RELAY_TOKEN" agentos-relay
227+
docker run -d -p 8080:8080 -v agentos-relay:/data -e RELAY_TOKEN="$RELAY_TOKEN" ghcr.io/synapsr/agentos-relay
231228
```
232229

233230
Celui qui exploite un relais peut lire tout ce qui y passe, y compris les
@@ -371,11 +368,13 @@ qui a été prouvé, et comment.
371368
- Le cœur, avec le cerveau d'entraînement et avec le vrai Claude
372369
- Le contrôle de l'ordinateur, par agent-desktop
373370
- Le client web
374-
- Le relais, lancé comme un simple processus
371+
- Le relais, lancé comme un simple processus, et son image publiée, déployée
372+
et testée de bout en bout à travers internet (appairage, une demande
373+
approuvée de l'extérieur, les événements en direct)
375374
- La voix ElevenLabs, avec une vraie clé
376-
- L'app de bureau, mode OS compris, et une `AgentOS.app` autonome (signée
377-
ad hoc, non notarisée ; il reste des vérifications visuelles à faire sur
378-
l'app empaquetée)
375+
- L'app de bureau, mode OS compris, et une `AgentOS.app` autonome, signée
376+
avec un Developer ID et notarisée par Apple (il reste des vérifications
377+
visuelles à faire sur l'app empaquetée)
379378
- L'app iPhone, dans le simulateur, face au vrai cœur
380379

381380
**Construit, pas encore vérifié en conditions réelles**
@@ -397,16 +396,15 @@ qui a été prouvé, et comment.
397396
- Le chiffrement de bout en bout à travers un relais
398397

399398
AgentOS a fait l'objet d'une revue de sécurité indépendante, ce qui n'est
400-
pas un audit, et il n'existe pas encore de version signée. Voyez-le comme un
399+
pas un audit. Voyez-le comme un
401400
logiciel jeune, et confiez-lui un ordinateur en conséquence :
402401
[SECURITY.md](SECURITY.md).
403402

404403
## La suite
405404

406405
Dans cet ordre, à peu près : vérifier la voix et les notifications sur du
407-
vrai matériel ; l'app compagnon sur une vraie Apple Watch ; des téléchargements signés pour macOS et
408-
des versions de l'app iPhone qu'on n'a pas à compiler ; une image publiée du
409-
relais ; le chiffrement de bout en bout à travers un relais ; Windows et
406+
vrai matériel ; l'app compagnon sur une vraie Apple Watch ; des versions de l'app iPhone
407+
qu'on n'a pas à compiler ; le chiffrement de bout en bout à travers un relais ; Windows et
410408
Linux ; Android. D'autres modèles peuvent déjà se brancher derrière
411409
l'interface du moteur, et sont les bienvenus.
412410

‎README.md‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ they finish. More in [Architecture](docs/ARCHITECTURE.md).
131131
[GitHub Releases](https://github.com/Synapsr/AgentOS/releases/latest), open
132132
it, and answer a few questions: your name, a name for your agent, how much
133133
it should ask, and a Claude key. That is where the website's *Download for
134-
Mac* leads. To be clear: the first version has not been published yet, so
135-
that page is still empty for now.
134+
Mac* leads. It is for Macs with Apple silicon (M1 or later), signed and
135+
notarized by Apple.
136136

137137
**For developers**, and anyone who wants it today: build it from source,
138138
with the [quick start](#quick-start) below.
@@ -210,12 +210,11 @@ that work, your choice:
210210
| **Your own relay** | One small container on a server you own | Your computer connects out to it, so nothing opens on your home network |
211211

212212
A relay is private: it does not start without a token, which your computer
213-
must present. Until an image is published, build it from your clone first
214-
(`docker build -f packages/relay/Dockerfile -t agentos-relay .`), then:
213+
must present. One line on any server with Docker:
215214

216215
```sh
217216
RELAY_TOKEN=$(openssl rand -hex 32)
218-
docker run -d -p 8080:8080 -v agentos-relay:/data -e RELAY_TOKEN="$RELAY_TOKEN" agentos-relay
217+
docker run -d -p 8080:8080 -v agentos-relay:/data -e RELAY_TOKEN="$RELAY_TOKEN" ghcr.io/synapsr/agentos-relay
219218
```
220219

221220
Whoever operates a relay can read everything that passes through it,
@@ -353,11 +352,13 @@ back, you answer. Here is what has been proven, and how.
353352
- The core, with the practice brain and with real Claude
354353
- Controlling the computer, through agent-desktop
355354
- The web client
356-
- The relay, run as a process
355+
- The relay, run as a process, and its published image, deployed and tested
356+
end to end through the internet (pairing, a request answered from outside,
357+
live events)
357358
- The ElevenLabs voice, with a real key
358-
- The desktop app, OS mode included, and a self-contained `AgentOS.app`
359-
(signed ad hoc, not notarized; some visual checks of the bundled app are
360-
still to do)
359+
- The desktop app, OS mode included, and a self-contained `AgentOS.app`,
360+
signed with a Developer ID and notarized by Apple (some visual checks of the
361+
bundled app are still to do)
361362
- The iPhone app, in the simulator, against the real core
362363

363364
**Built, not yet verified in real conditions**
@@ -377,15 +378,15 @@ back, you answer. Here is what has been proven, and how.
377378
- Android: planned, nothing exists
378379
- End-to-end encryption through a relay
379380

380-
AgentOS has had one independent security review, which is not an audit, and
381-
there are no signed builds yet. Treat it as early software, and give it a
382-
computer accordingly: [SECURITY.md](SECURITY.md).
381+
AgentOS has had one independent security review, which is not an audit.
382+
Treat it as early software, and give it a computer accordingly:
383+
[SECURITY.md](SECURITY.md).
383384

384385
## Roadmap
385386

386387
In rough order: verifying voice and notifications on real hardware; the
387-
Apple Watch companion on a real watch; signed downloads for macOS and builds of the iPhone app
388-
you do not have to compile; a published relay image; end-to-end encryption
388+
Apple Watch companion on a real watch; builds of the iPhone app you do not
389+
have to compile; end-to-end encryption
389390
through a relay; Windows and Linux; Android. Other models can already plug
390391
in behind the engine interface, and are welcome.
391392

‎SECURITY.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ says how to report a problem, who is trusted with what, what the safety
66
rules do and do not do, and where the limits are.
77

88
AgentOS is at version 0.1. It has had one independent security review, which
9-
is not an audit. There are no signed or notarized builds yet. Treat it as
10-
early software.
9+
is not an audit. Treat it as early software.
1110

1211
## Reporting a problem
1312

@@ -178,8 +177,10 @@ judgement, not something AgentOS enforces.
178177
what it sees goes to the model. The picture attached to a request is
179178
stored on the computer and shown on your devices, through the relay if you
180179
use one.
181-
- **No signed builds.** You build AgentOS from source. An app you build
182-
yourself is signed ad hoc and not notarized by Apple.
180+
- **Builds.** The Mac app on GitHub Releases is signed with the Synapsr
181+
Developer ID and notarized by Apple; check its SHA-256 against the
182+
`.sha256` file next to it. An app you build yourself is signed ad hoc and
183+
not notarized.
183184
- **Early software.** Version 0.1, one independent review, no audit. Parts
184185
of it have not yet run on real hardware: the README's
185186
[Status](README.md#status) lists them.

‎apps/desktop/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"private": true,
77
"scripts": {
88
"tauri": "tauri",
9-
"bundle": "tauri build --config src-tauri/tauri.bundle.conf.json"
9+
"bundle": "tauri build --config src-tauri/tauri.bundle.conf.json",
10+
"notarize": "node scripts/notarize.mjs"
1011
},
1112
"devDependencies": {
1213
"@tauri-apps/cli": "^2.12.0"

‎apps/desktop/scripts/notarize.mjs‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Has Apple notarize the app and the disk image `pnpm desktop:bundle` made,
4+
* and staples the tickets to them, so that both open on any Mac without a
5+
* warning, even offline:
6+
*
7+
* 1. the app goes to Apple's notary service and gets its ticket stapled;
8+
* 2. the disk image Tauri made is opened, the app inside it gets the same
9+
* ticket, and the image is compressed and signed again;
10+
* 3. the disk image goes to the notary service and gets its ticket stapled;
11+
* 4. both are checked the way Gatekeeper checks them, and the image's
12+
* SHA-256 is written next to it.
13+
*
14+
* Why the app is stapled as well as the image: the app's own ticket goes
15+
* wherever the app is copied, the image's stays with the image. The image is
16+
* opened rather than made again so that it keeps Tauri's layout.
17+
*
18+
* The bundle must have been built with a Developer ID in
19+
* APPLE_SIGNING_IDENTITY: Apple refuses to notarize an app signed ad hoc.
20+
*
21+
* Environment:
22+
* AGENTOS_NOTARY_PROFILE the notarytool keychain profile to send with
23+
* (made once with `xcrun notarytool store-credentials`)
24+
* APPLE_SIGNING_IDENTITY the identity that signs the disk image; default:
25+
* the one that signed the app
26+
*/
27+
import { spawnSync } from "node:child_process";
28+
import { createHash } from "node:crypto";
29+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
30+
import { basename, dirname, join } from "node:path";
31+
import { fileURLToPath } from "node:url";
32+
33+
const desktop = join(dirname(fileURLToPath(import.meta.url)), "..");
34+
const config = JSON.parse(readFileSync(join(desktop, "src-tauri/tauri.conf.json"), "utf8"));
35+
const bundle = join(desktop, "src-tauri/target/release/bundle");
36+
// The names Tauri gives what it makes.
37+
const arch = { arm64: "aarch64", x64: "x64" }[process.arch];
38+
const app = join(bundle, "macos", `${config.productName}.app`);
39+
const dmg = join(bundle, "dmg", `${config.productName}_${config.version}_${arch}.dmg`);
40+
// Work files stay in the build folder, which is not part of the repository.
41+
const work = join(bundle, "notarize");
42+
43+
const say = (line) => console.log(`notarize: ${line}`);
44+
45+
/** Stops the release with a message. Thrown, so that disk images opened on the way are closed again. */
46+
function fail(message) {
47+
throw Object.assign(new Error(message), { expected: true });
48+
}
49+
50+
/** Runs a command and returns what it printed; fails when the command does, unless told not to. */
51+
function run(command, args, { check = true } = {}) {
52+
const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
53+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
54+
if (check && result.status !== 0) fail(`${command} ${args.join(" ")} failed:\n${output || result.error?.message}`);
55+
return { stdout: result.stdout ?? "", output };
56+
}
57+
58+
/** Sends a file to Apple's notary service and waits for its answer; prints Apple's log when it says no. */
59+
function notarize(file, profile) {
60+
say(`sending ${basename(file)} to Apple, which takes a few minutes`);
61+
const sent = run("xcrun", ["notarytool", "submit", file, "--keychain-profile", profile, "--wait", "--output-format", "json"], { check: false });
62+
const answer = sent.stdout
63+
.trim()
64+
.split("\n")
65+
.reverse()
66+
.map((line) => {
67+
try {
68+
return JSON.parse(line);
69+
} catch {
70+
return null;
71+
}
72+
})
73+
.find((parsed) => parsed?.status);
74+
if (!answer) fail(`notarytool did not answer as expected:\n${sent.output}`);
75+
if (answer.status !== "Accepted") {
76+
const log = answer.id ? run("xcrun", ["notarytool", "log", answer.id, "--keychain-profile", profile], { check: false }).output : "";
77+
fail(`Apple did not accept ${basename(file)}: ${answer.status}, ${answer.message ?? ""}\n${log}`);
78+
}
79+
say(`Apple accepted ${basename(file)} (submission ${answer.id})`);
80+
}
81+
82+
/** Opens a disk image where only this script sees it (not in the Finder), runs `use`, and closes it again. */
83+
function withImage(image, mountpoint, options, use) {
84+
mkdirSync(mountpoint, { recursive: true });
85+
run("hdiutil", ["attach", image, "-nobrowse", "-noautoopen", "-mountpoint", mountpoint, "-quiet", ...options]);
86+
try {
87+
use(mountpoint);
88+
} finally {
89+
run("hdiutil", ["detach", mountpoint, "-quiet"], { check: false });
90+
}
91+
}
92+
93+
/** Runs one of Gatekeeper's checks and prints its answer, which must say Apple's ticket was found. */
94+
function check(label, command, args) {
95+
const { output } = run(command, args);
96+
// spctl accepts any Developer ID; only "Notarized" means it found Apple's ticket.
97+
if (command === "spctl" && !output.includes("source=Notarized Developer ID")) fail(`${label} is accepted, but not as notarized:\n${output}`);
98+
say(`${label}: ${output.split("\n").filter((line) => !line.startsWith("Processing")).join(" | ")}`);
99+
}
100+
101+
async function release() {
102+
if (process.platform !== "darwin") fail("notarizing is for macOS builds, made on a Mac.");
103+
const profile = process.env.AGENTOS_NOTARY_PROFILE || fail("set AGENTOS_NOTARY_PROFILE to your notarytool keychain profile (xcrun notarytool store-credentials).");
104+
for (const made of [app, dmg]) if (!existsSync(made)) fail(`${made} is missing: run pnpm desktop:bundle first.`);
105+
106+
const authority = /^Authority=(Developer ID Application: .+)$/m.exec(run("codesign", ["-dvv", app]).output)?.[1];
107+
if (!authority) fail(`${basename(app)} is not signed with a Developer ID: build it again with APPLE_SIGNING_IDENTITY set.`);
108+
const identity = process.env.APPLE_SIGNING_IDENTITY || authority;
109+
run("codesign", ["--verify", "--deep", "--strict", app]);
110+
111+
rmSync(work, { recursive: true, force: true });
112+
mkdirSync(work, { recursive: true });
113+
114+
// 1. The app.
115+
const zip = join(work, `${config.productName}.zip`);
116+
run("ditto", ["-c", "-k", "--keepParent", app, zip]);
117+
notarize(zip, profile);
118+
run("xcrun", ["stapler", "staple", app]);
119+
120+
// 2. The app inside the image.
121+
const writable = join(work, "writable.dmg");
122+
run("hdiutil", ["convert", dmg, "-format", "UDRW", "-o", writable, "-quiet"]);
123+
withImage(writable, join(work, "volume"), [], (volume) => {
124+
run("xcrun", ["stapler", "staple", join(volume, basename(app))]);
125+
// What macOS writes on a volume it opens writable does not ship.
126+
rmSync(join(volume, ".fseventsd"), { recursive: true, force: true });
127+
});
128+
const packed = join(work, basename(dmg));
129+
// Compressed as Tauri compresses it.
130+
run("hdiutil", ["convert", writable, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", packed, "-quiet"]);
131+
run("codesign", ["--force", "--sign", identity, "--timestamp", packed]);
132+
renameSync(packed, dmg);
133+
134+
// 3. The image.
135+
notarize(dmg, profile);
136+
run("xcrun", ["stapler", "staple", dmg]);
137+
138+
// 4. As Gatekeeper sees them, the app inside the image included.
139+
check("app, signature", "codesign", ["--verify", "--deep", "--strict", "--verbose=2", app]);
140+
check("app, ticket", "xcrun", ["stapler", "validate", app]);
141+
check("app, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "exec", app]);
142+
check("image, ticket", "xcrun", ["stapler", "validate", dmg]);
143+
check("image, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "open", "--context", "context:primary-signature", dmg]);
144+
withImage(dmg, join(work, "shipped"), ["-readonly"], (volume) => {
145+
const inside = join(volume, basename(app));
146+
check("app in the image, ticket", "xcrun", ["stapler", "validate", inside]);
147+
check("app in the image, Gatekeeper", "spctl", ["-a", "-vvv", "-t", "exec", inside]);
148+
});
149+
150+
const hash = createHash("sha256");
151+
for await (const chunk of createReadStream(dmg)) hash.update(chunk);
152+
writeFileSync(`${dmg}.sha256`, `${hash.digest("hex")} ${basename(dmg)}\n`);
153+
rmSync(work, { recursive: true, force: true });
154+
155+
say(`done: ${dmg} (${(statSync(dmg).size / 1024 / 1024).toFixed(1)} MB), signed by ${authority}, notarized and stapled`);
156+
say(`its SHA-256 is in ${basename(dmg)}.sha256`);
157+
}
158+
159+
try {
160+
await release();
161+
} catch (error) {
162+
console.error(`notarize: ${error.expected ? error.message : error.stack}`);
163+
process.exitCode = 1;
164+
}

0 commit comments

Comments
 (0)