From dd9b649c8cf1460c9d406b436e6acf6040ebce23 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 1 Oct 2026 15:50:57 -0700 Subject: [PATCH] Verify Windows credential rotation and prepare beta download routes --- aesel/windows/Aesel.csproj | 1 - aesel/windows/NativeAuth.cs | 7 +++- aesel/windows/README.md | 53 +++++++++++++++++++++++++++ aesel/windows/SmokeTest.cs | 35 +++++++++++++++++- system/backend/app-downloads.mjs | 14 +++++++ system/netlify/functions/download.mjs | 16 +++----- system/public/aesel/index.html | 15 +++++--- system/public/aesel/privacy.html | 6 +-- tests/app-downloads.test.mjs | 14 +++++++ 9 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 aesel/windows/README.md create mode 100644 system/backend/app-downloads.mjs create mode 100644 tests/app-downloads.test.mjs diff --git a/aesel/windows/Aesel.csproj b/aesel/windows/Aesel.csproj index 807381d006..a3646375d6 100644 --- a/aesel/windows/Aesel.csproj +++ b/aesel/windows/Aesel.csproj @@ -13,7 +13,6 @@ - diff --git a/aesel/windows/NativeAuth.cs b/aesel/windows/NativeAuth.cs index 2ab479efaf..68005250c1 100644 --- a/aesel/windows/NativeAuth.cs +++ b/aesel/windows/NativeAuth.cs @@ -13,11 +13,12 @@ namespace Aesel; // One app process and one serialized credential owner. Rotating refresh tokens // stay in a CurrentUser DPAPI envelope; only access tokens enter the WebView. -internal sealed class NativeAuth(string directory) +internal sealed class NativeAuth(string directory, HttpClient? transport = null) { internal const string ClientId = "LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt"; internal const string Callback = "http://localhost:44233/callback"; - static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) }; + static readonly HttpClient DefaultHttp = new() { Timeout = TimeSpan.FromSeconds(30) }; + readonly HttpClient Http = transport ?? DefaultHttp; readonly SemaphoreSlim gate = new(1, 1); readonly string file = Path.Combine(directory, "account.dat"); sealed record Credentials(string Access, string? Refresh, long Expires); @@ -36,6 +37,8 @@ internal sealed class NativeAuth(string directory) File.Move(file + ".new", file, true); } + internal void SeedSmokeCredential() => Save(new("expired-fixture-token", "fixture-refresh", 0)); + async Task Exchange(object body, string? previousRefresh = null) { using var response = await Http.PostAsJsonAsync("https://hi.aesthetic.computer/oauth/token", body); diff --git a/aesel/windows/README.md b/aesel/windows/README.md new file mode 100644 index 0000000000..2bef8a06e7 --- /dev/null +++ b/aesel/windows/README.md @@ -0,0 +1,53 @@ +# Aesel for Windows + +Windows 10/11 x64 beta. WPF + WebView2 hosts a bundled copy of Aesel's shared +JavaScript session and browser interface: AC account sign-in, hosted generation, +local notebooks, automatic publishing, source download, and piece preview. +This release does not include the Mac interface, terminal app, Claude/Codex CLI +providers, or automatic updates. It is unsigned; Windows may show an unknown +publisher or SmartScreen notice. No security setting needs to be disabled. + +Sign-in opens the system browser using AC's existing PKCE registration and +`http://localhost:44233/callback`. The receiver binds only to 127.0.0.1, bounds +request size and duration, and rejects mismatched/duplicate state parameters. +Refresh credentials stay in a Windows CurrentUser DPAPI envelope. The web view +receives access tokens through a bridge restricted to the bundled top document; +it has no filesystem or process host objects. External HTTPS links open in the +browser. One app process owns credential rotation. + +Data lives under `%LOCALAPPDATA%\Aesthetic Computer\Aesel`. Notebooks are +separated by account and survive updates and uninstall. Sign out removes saved +credentials. Delete that directory separately to erase all local notebooks and +credentials. There is no cross-device notebook sync. Hosted inference and +publication use AC's network services. A launch sends the existing anonymous +`app-open` schema (`app`, version, Windows platform, random install UUID, first +launch flag); create an empty `disable-launch-ping` file in the data directory +to disable it. No account identity is included in that ping. + +Build on Windows with Node 24, .NET 10 SDK, Microsoft WebView2 Runtime, and Inno +Setup 6 installed: + +```powershell +npm ci --prefix aesel/web +node aesel/windows/prepare.mjs +pwsh -File aesel/windows/package.ps1 +``` + +`package.ps1` publishes the self-contained x64 app, runs it on Windows with an +isolated profile and mocked account/inference/upload traffic, captures the +workspace/sign-in screens, then builds the per-user installer and SHA-256 feed. +The smoke checks generation, upload, preview, escaping, persistence, thread +switching, sign-out, account isolation, callback boundaries and Windows DPAPI. +It does not certify real-user OAuth or provider billing. + +The existing AppVeyor project builds the `aesel-windows` branch using its +separate branch configuration. GitHub's manual workflow is also available when +the account billing lock is cleared. Inspect both screenshots and the test +result before publication; never publish a compile-only artifact. + +Release artifacts go under `releases.aesthetic.computer/aesel/windows/`. +Upload the immutable installer first and `latest.json` last. The manifest records +the build revision, hash, architecture, beta channel, and unsigned status. +Only a revision preserved on knot `main` may be published. The website links +through `/api/download?app=aesel&file=aesel-VERSION-windows-x64-setup.exe` so +Windows downloads use the existing AC download reporting. diff --git a/aesel/windows/SmokeTest.cs b/aesel/windows/SmokeTest.cs index e61d8e0f1a..307127f85a 100644 --- a/aesel/windows/SmokeTest.cs +++ b/aesel/windows/SmokeTest.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Net; +using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -13,6 +15,21 @@ internal static class SmokeTest static int rounds; static string uploaded = ""; const string Source = "export function paint({ wipe }) { wipe(\"purple\"); }\n"; + sealed class AuthTransport : HttpMessageHandler + { + internal int Refreshes; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancel) + { + if (request.RequestUri?.AbsoluteUri == "https://hi.aesthetic.computer/oauth/token") { + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancel)); + if (body.RootElement.GetProperty("refresh_token").GetString() != "fixture-refresh") throw new Exception("Wrong refresh credential."); + Refreshes++; + await Task.Delay(30, cancel); + return new(HttpStatusCode.OK) { Content = new StringContent("{\"access_token\":\"native-smoke-token\",\"refresh_token\":\"rotated-fixture\",\"expires_in\":3600}") }; + } + throw new Exception("Unexpected native auth request: " + request.RequestUri); + } + } internal static void Attach(CoreWebView2 core) { core.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All); @@ -82,10 +99,26 @@ internal static class SmokeTest MainWindow.IsApp("https://aesel.app/anything") || MainWindow.IsApp("https://user@aesel.app/try/")) throw new Exception("Callback/origin boundary failed."); var secret = Encoding.UTF8.GetBytes("test-only-credential"); if (!ProtectedData.Unprotect(ProtectedData.Protect(secret, null, DataProtectionScope.CurrentUser), null, DataProtectionScope.CurrentUser).SequenceEqual(secret)) throw new Exception("Windows credential encryption failed."); + var transport = new AuthTransport(); + using var http = new HttpClient(transport); + var credentialDirectory = Path.Combine(directory, "credential-check"); + Directory.CreateDirectory(credentialDirectory); + var credentials = new NativeAuth(credentialDirectory, http); + credentials.SeedSmokeCredential(); + var renewed = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => credentials.Call("getTokenSilently", CancellationToken.None))); + if (transport.Refreshes != 1 || renewed.Any(token => (string?)token != "native-smoke-token")) throw new Exception("Concurrent token renewal failed."); + var envelope = File.ReadAllBytes(Path.Combine(credentialDirectory, "account.dat")); + if (Encoding.UTF8.GetString(envelope).Contains("fixture")) throw new Exception("Unencrypted credential."); + var decoded = Encoding.UTF8.GetString(ProtectedData.Unprotect(envelope, null, DataProtectionScope.CurrentUser)); + if (!decoded.Contains("rotated-fixture")) throw new Exception("Rotated refresh token was not saved."); + await credentials.Call("logout", CancellationToken.None); + if ((bool)(await credentials.Call("isAuthenticated", CancellationToken.None))! || File.Exists(Path.Combine(credentialDirectory, "account.dat"))) throw new Exception("Sign-out retained credentials."); await Until(core, "!!document.getElementById('workspace') && !document.getElementById('workspace').hidden"); await core.ExecuteScriptAsync("document.getElementById('input').value='Make a purple piece.';document.getElementById('send').click();"); await Until(core, "!!document.querySelector('#log .answer') && !document.getElementById('preview').hidden && !document.getElementById('send').disabled"); - if (rounds != 2 || uploaded != Source) throw new Exception($"Generation/upload failed: {rounds} requests, {uploaded.Length} bytes."); + var uploadDeadline = DateTime.UtcNow.AddSeconds(15); + while (uploaded != Source && DateTime.UtcNow < uploadDeadline) await Task.Delay(100); + if (rounds != 2 || uploaded != Source) throw new Exception($"Generation/upload failed: {rounds} requests, {JsonSerializer.Serialize(uploaded)}."); await Until(core, "document.querySelectorAll('#log img').length===0 && !JSON.stringify(localStorage).includes('windows-smoke-token')"); var id = await core.ExecuteScriptAsync("document.getElementById('history').value"); core.Reload(); diff --git a/system/backend/app-downloads.mjs b/system/backend/app-downloads.mjs new file mode 100644 index 0000000000..b9b014005f --- /dev/null +++ b/system/backend/app-downloads.mjs @@ -0,0 +1,14 @@ +// Fixed release hosts and exact filenames only; never redirect an arbitrary URL. +const releases = [ + ['slab', /^Slab-(\d+(?:\.\d+){1,2})\.dmg$/, 'https://assets.aesthetic.computer/slab/'], + ['aesel', /^aesel-(\d+(?:\.\d+){1,2})-arm64\.dmg$/, 'https://releases.aesthetic.computer/aesel/mac/'], + ['aesel', /^aesel-(\d+(?:\.\d+){1,2})-windows-x64-setup\.exe$/, 'https://releases.aesthetic.computer/aesel/windows/'], +]; +export function resolveAppDownload(app, file) { + if (typeof app !== 'string' || typeof file !== 'string') return null; + for (const [name, pattern, base] of releases) { + const match = app === name && file.match(pattern); + if (match) return {version:match[1], location:base+file}; + } + return null; +} diff --git a/system/netlify/functions/download.mjs b/system/netlify/functions/download.mjs index 526d9204f6..4740ca1f27 100644 --- a/system/netlify/functions/download.mjs +++ b/system/netlify/functions/download.mjs @@ -13,15 +13,10 @@ import { connect } from "../../backend/database.mjs"; import { createHmac } from "node:crypto"; import { respond } from "../../backend/http.mjs"; import { automatedVisit } from "../../public/aesthetic.computer/lib/visit-model.mjs"; +import { resolveAppDownload } from "../../backend/app-downloads.mjs"; export const DOWNLOAD_COLLECTION = "downloads"; -// Only these files redirect, so this can't be used as an open redirect. -const APPS = { - slab: { base: "https://assets.aesthetic.computer/slab/", file: /^Slab-(\d+(?:\.\d+){1,2})\.dmg$/ }, - aesel: { base: "https://releases.aesthetic.computer/aesel/mac/", file: /^aesel-(\d+(?:\.\d+){1,2})-arm64\.dmg$/ }, -}; - // Keyed on a secret lith already holds, so the hash can't be reversed by // hashing every address, and it stays stable across restarts. const key = createHmac("sha256", "ac-download-v1") @@ -56,16 +51,15 @@ export async function handler(event) { if (query.whoami !== undefined) return respond(200, { hash: addressHash(event) }, headers); - const app = APPS[query.app]; - const match = app && typeof query.file === "string" && query.file.match(app.file); - if (!match) return respond(404, { error: "Unknown download" }, headers); + const download = resolveAppDownload(query.app, query.file); + if (!download) return respond(404, { error: "Unknown download" }, headers); - const location = app.base + query.file; + const { location, version } = download; if (event.httpMethod === "GET") { const agent = event.headers?.["user-agent"] || ""; const at = new Date(); const row = { - app: query.app, version: match[1], file: query.file, at, day: at.toISOString().slice(0, 10), + app: query.app, version, file: query.file, at, day: at.toISOString().slice(0, 10), hash: addressHash(event), country: event.headers?.["cf-ipcountry"] || null, platform: platform(agent), automated: automatedVisit({ userAgent: agent }), from: typeof query.from === "string" ? query.from.slice(0, 32) : null, diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index b75dd372d1..5fcc46357e 100644 --- a/system/public/aesel/index.html +++ b/system/public/aesel/index.html @@ -4,11 +4,11 @@ Aesel - + - + @@ -20,16 +20,19 @@

aesel

-Download the Mac beta -

0.8.3 beta · Apple silicon · macOS 14+

+Download the Mac beta +

0.8.19 beta · Apple silicon · macOS 14+

+Download the Windows beta +

0.8.19 beta · Windows 10/11 · x64 · AC braincells

An Aesel notebook with four revisions of a spinning maple leaf beside its live preview
Aesel tiled beside coding agents on a Slab desktop
-
Install & open

Drag Aesel to Applications, open it, and sign in with your Aesthetic Computer account.

aesel opens the app from a terminal. aes runs it in the terminal.

-

Use requires sharing conversations and artifact revision references with authorized Aesthetic Computer staff for product improvement, even with your own provider. Privacy policy

+
Install on Mac

Drag Aesel to Applications, open it, and sign in with your Aesthetic Computer account.

aesel opens the app from a terminal. aes runs it in the terminal.

+
Install on Windows

Run the installer, open Aesel, and sign in with your Aesthetic Computer account in your browser. Requires Microsoft WebView2 Runtime.

This first beta uses AC braincells. Claude/Codex CLI support is not included. The installer is unsigned; Windows may show an unknown-publisher or SmartScreen notice.

+

Conversation context goes to the services that generate your work. Pieces publish to your @handle; notebooks stay on your device. Privacy policy

diff --git a/system/public/aesel/privacy.html b/system/public/aesel/privacy.html index 29614c19c2..0dc40445f2 100644 --- a/system/public/aesel/privacy.html +++ b/system/public/aesel/privacy.html @@ -1,6 +1,6 @@ Aesel privacyAesel

Aesel privacy

Aesel is made by Aesthetic Computer. Contact mail@aesthetic.computer about your data.

-

Account and projects

Sign-in uses your Aesthetic Computer account, including your email address, user ID, and handle. Projects and conversation history are saved on your Mac. Aesel sends project content to the services needed to generate, preview, share, or publish your work.

+

Account and projects

Sign-in uses your Aesthetic Computer account, including your email address, user ID, and handle. Projects and conversation history are saved on your device. Windows sign-in opens your browser; saved credentials are encrypted for your Windows user account. Uninstalling the Windows app retains notebooks and credentials; sign out first or remove its local app-data folder to erase them. Aesel sends project content to the services needed to generate, preview, share, or publish your work.

AI services

Your prompts, relevant conversation history, source code, and tool results are sent to your selected AI provider to answer requests. Hosted text generation uses OpenRouter and the selected model provider. Hosted image generation sends prompts and any supplied reference image to OpenAI. If you connect your own provider, that provider receives the context needed for your requests. These services process data under their own privacy policies.

OpenRouter privacy · OpenAI privacy · Anthropic privacy

-

Required transcript sharing

Aesel requires agreement before use to share messages, assistant replies, and artifact revision references with Aesthetic Computer for product improvement, including when using your own provider. Access to uploaded transcripts is restricted to authorized AC staff. Uploaded transcripts are retained indefinitely until you delete them. Recognizable credentials are redacted, but messages may still contain personal information. Earlier private conversations are not uploaded.

Use /transcript to export a conversation and /sharing delete to delete its uploaded transcript. Future messages continue to be shared under the required policy. Quit Aesel if you do not agree to this sharing.

+

Conversations

Native and browser notebooks stay in local app or browser storage. Hosted inference sends conversation context to AC and its inference provider. These clients do not upload a separate transcript journal.

Non-private terminal sessions ask for agreement to share their transcript with authorized AC staff. Uploaded transcripts remain until deletion. Use /transcript delete in the terminal to delete that uploaded copy. Future messages may be uploaded under the accepted policy. Private terminal mode omits this journal; inference and account verification still use the network.

Live and published work

Aesel creates live preview links so people with the link or QR code can watch an artifact develop. Treat shared previews as public. Published pieces and media are accessible on Aesthetic Computer and may be federated through its AT Protocol service. Deleting a transcript does not remove published artwork.

-

Operations and deletion

AC services process usage, errors, and network request information to operate the service, enforce generation limits, and diagnose failures. We do not sell your data. To delete your AC account, enter delete-erase-and-forget-me on Aesthetic Computer. You can also contact us for help with deletion.

See the Aesthetic Computer privacy policy for the wider service.

Updated September 21, 2026.

+

Operations and deletion

AC services process usage, errors, and network request information to operate the service, enforce generation limits, and diagnose failures. We do not sell your data. To delete your AC account, enter delete-erase-and-forget-me on Aesthetic Computer. You can also contact us for help with deletion.

See the Aesthetic Computer privacy policy for the wider service.

Updated October 1, 2026.

diff --git a/tests/app-downloads.test.mjs b/tests/app-downloads.test.mjs new file mode 100644 index 0000000000..db5c8b5d04 --- /dev/null +++ b/tests/app-downloads.test.mjs @@ -0,0 +1,14 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {resolveAppDownload} from '../system/backend/app-downloads.mjs'; +test('existing Mac releases keep their destinations',()=>{ + assert.equal(resolveAppDownload('aesel','aesel-0.8.3-arm64.dmg').location,'https://releases.aesthetic.computer/aesel/mac/aesel-0.8.3-arm64.dmg'); + assert.equal(resolveAppDownload('slab','Slab-1.1.dmg').version,'1.1'); +}); +test('Windows Aesel has its own release prefix',()=>{ + assert.deepEqual(resolveAppDownload('aesel','aesel-0.8.19-windows-x64-setup.exe'),{version:'0.8.19',location:'https://releases.aesthetic.computer/aesel/windows/aesel-0.8.19-windows-x64-setup.exe'}); +}); +test('unknown apps, architectures and URL/path injection do not redirect',()=>{ + for(const app of ['__proto__','constructor','unknown',null,{}]) assert.equal(resolveAppDownload(app,'aesel-0.8.19-windows-x64-setup.exe'),null); + for(const file of [null,{},'../aesel-0.8.19-windows-x64-setup.exe','https://evil.test/aesel-0.8.19-windows-x64-setup.exe','aesel-0.8.19-windows-arm64-setup.exe','aesel-0.8.19-windows-x64-setup.exe?url=https://evil.test','aesel-0.8.19-windows-x64-setup.exe\n']) assert.equal(resolveAppDownload('aesel',file),null); +}); -- 2.51.2